飞道的博客

网络编程(一)

432人阅读  评论(0)

网络编程

文章目录

前置概念

1- 字节序

字节序经常被分为两类:

    1. Big-Endian(大端):高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。
  • 2.Little-Endian(小端):低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。

高低地址与高低字节

高低地址:

C程序映射中内存的空间布局大致如下:

最高内存地址 0xFFFFFFFF

栈区(从高内存地址,往 低内存地址发展。即栈底在高地址,栈顶在低地址)

堆区(从低内存地址 ,往 高内存地址发展)

全局区(常量和全局变量)

代码区

最低内存地址 0x00000000

高低字节

在十进制中靠左边的是高位,靠右边的是低位,在其他进制也是如此。例如 0x12345678,从高位到低位的字节依次是0x12、0x34、0x56和> 0x78。

网络字节序 就是 大端字节序:4个字节的32 bit值以下面的次序传输,首先是0~7bit,其次8~15bit,然后16~23bit,最后是24~31bit

主机字节序 就是 小端字节序,现代PC大多采用小端字节序。

字节序大端小端例子

对于数据 0x12345678,假设从地址0x4000开始存放,在大端和小端模式下,存放的位置分别为:

内存地址 小端模式 大端模式
0x4003 0x12 0x78
0x4002 0x34 0x56
0x4001 0x56 0x34
0x4000 0x78 0x12

采用Little-endian模式的CPU对操作数的存放方式是从低字节到高字节,而Big-endian模式对操作数的存放方式是从高字节到低字节。

小端存储后:0x78563412 大端存储后:0x12345678

代码判断当前机器是大端还是小端

void byteorder()
{
   
	union
	{
   
		short value;
		char union_bytes[sizeof(short)];
	}test;
	test.value = 0x0102;
 
	if (sizeof(short) == 2)
	{
   
		if (test.union_bytes[0] == 1 && test.union_bytes[1] == 2)
			cout << "big endian" << endl;
		else if (test.union_bytes[0] == 2 && test.union_bytes[1] == 1)
			cout << "little endian" << endl;
		else
			cout << "unknown" << endl;
	}
	else
	{
   
		cout << "sizeof(short) == " << sizeof(short) << endl;
	}
 
	return ;
}

上述代码,使用了联合体union,所有成员共用同一块内存的特性。

一般,主机字节序,都是小端模式。

为何要有字节序

很多人会问,为什么会有字节序,统一用大端序不行吗?答案是,计算机电路先处理低位字节,效率比较高,因为计算都是从低位开始的。所以,计算机的内部处理都是小端字节序。在计算机内部,小端序被广泛应用于现代 CPU 内部存储数据;而在其他场景,比如网络传输和文件存储则使用大端序

字节序转换函数

uint32_t htonl(uint32_t hostlong);

uint16_t htons(uint16_t hostshort);

uint32_t ntohl(uint32_t netlong);

uint16_t ntohs(uint16_t netshort);
  • h表示host,指小端,n表示network指大端,l表示32位长整数,s表示16位短整数。

  • 注意:32位是用来转换IP地址的,16位是用来转换端口号的

需要字节序转换的时机

端口和IP地址是16位或者32为多字节数据,需要大小端转换,但是在数据传输过程中,都是以字符串的形式传输的,字符串中每个字符只有8位,也就是一个字节,无论在大端还是小端,结果都是一样的(这需要对大小端概念有一个比较清晰的理解)

比如 “scsadvsdvsad”“中文名” 都是串 都不需要转

例子一

printf_bin(int num) 这个函数将整形变量以二进制的形式打印出来

#include <stdio.h>
#include <arpa/inet.h>
void printf_bin(int num) // 这个函数将整形变量以二进制的形式打印出来
{
   
    int i, j, k;
    unsigned char *p = (unsigned char *)&num + 3; // p先指向num后面第3个字节的地址,即num的最高位字节地址

    for (i = 0; i < 4; i++) // 依次处理4个字节(32位)
    {
   
        j = *(p - i);                // 取每个字节的首地址,从高位字节到低位字节,即p p-1 p-2 p-3地址处
        for (int k = 7; k >= 0; k--) // 处理每个字节的8个位,注意字节内部的二进制数是按照人的习惯存储!
        {
   
            if (j & (1 << k)) // 1左移k位,与单前的字节内容j进行或运算,如k=7时,00000000&10000000=0 ->该字节的最高位为0
                printf("1");
            else
                printf("0");
        }
        printf(" "); // 每8位加个空格,方便查看
    }
    printf("\r\n");
}

int main()
{
   
    int n = 10;
    printf("打印出n的32位数据\n");
    printf_bin(n);
    printf("进行大小端转换\n");
    unsigned int n1 = htonl(n);

    printf("打印出n的32位数据\n");
    printf_bin(n);
    printf("打印出n1的32位数据\n");
    printf_bin(n1);
}

运行结果:

打印出n的32位数据
00000000 00000000 00000000 00001010 
进行大小端转换
打印出n的32位数据
00000000 00000000 00000000 00001010 
打印出n1的32位数据
00001010 00000000 00000000 00000000 

可见整型变量 n 就像是一个容器,能存放 32 位的数据, 数据默认是小端存储的, htonl 转换n容器 后, n原先的大小端存储方式没变,但返回出了大小端存储方式转换的容器n1 .

例子二

// todo 网络字节序和本地字节序的转换  (大端二进制和小端二进制的转换)
#include <stdio.h>
#include <arpa/inet.h>
void printf_bin(int num) // 这个函数将整形变量以二进制的形式打印出来
{
   
    int i, j, k;
    unsigned char *p = (unsigned char *)&num + 3; // p先指向num后面第3个字节的地址,即num的最高位字节地址

    for (i = 0; i < 4; i++) // 依次处理4个字节(32位)
    {
   
        j = *(p - i);                // 取每个字节的首地址,从高位字节到低位字节,即p p-1 p-2 p-3地址处
        for (int k = 7; k >= 0; k--) // 处理每个字节的8个位,注意字节内部的二进制数是按照人的习惯存储!
        {
   
            if (j & (1 << k)) // 1左移k位,与单前的字节内容j进行或运算,如k=7时,00000000&10000000=0 ->该字节的最高位为0
                printf("1");
            else
                printf("0");
        }
        printf(" "); // 每8位加个空格,方便查看
    }
    printf("\r\n");
}

int main()
{
   

    char buf[4] = {
   192, 168, 1, 2}; // 32位

    // todo1 将 4字节(32位)的数据存放在 num容器(int 类型, 32位)中
    // todo 也就是取出32位数据
    unsigned int num = *(unsigned int *)buf; // int*把buf(char类型的数组首地址强转为int*类型的地址),
                                             // 再*(解引用)取出四个字节的数据,而int 类型刚好是4字节,就能存放这四字节数据

    // 你可以把 int num 当成是定义了一个能存放32位数据的容器,只是这32位存放的是
    // 192.168.1.2 的用二进制(01)表示的情况

    printf("打印出num容器中的32位数据\n");
    printf_bin(num);

    printf("打印出num的值\n");
    printf("%u\n", num); //%u用于打印 unsigned int .
    // 打印结果 33663168 . 这么大是因为 他不会每八位隔断,每八位分别做二进制转换
    //(像把 00000010 00000001 10101000 11000000)隔断为 192.168.1.2 而是
    // 直接将这个32位的数作为整体进行二进制转换
    printf("================\n");
    int n1 = 33663168; // 实际上这个十进制数用二进制的表示就是 00000010 00000001 10101000 11000000

    printf("打印出n1容器中的32位数据\n");
    printf_bin(n1);
    // todo2  htol() 函数的作用是将一个32位数从主机字节顺序转换成网络字节顺序。
    unsigned int sum = htonl(num);
    printf("打印出sum容器中的32位数据\n");
    printf_bin(sum); // 11000000 10101000 00000001 00000010  和 num中的二进制位是相反的
    // todo 3 每四位取出数据.

    printf("sum容器中32位数据,每四位取出,并打印\n");
    unsigned char *p = &sum;
    printf("%d %d %d %d\n", *p, *(p + 1), *(p + 2), *(p + 3));
    // todo 逆过程  ntohl  函数的作用是将一个32位数从网络字节顺序转换成主机字节顺序
    printf("sum2容器中32位数据,每四位取出,并打印\n");
    unsigned int sum2 = ntohl(sum);
    unsigned char *p2 = &sum2;
    printf("%d %d %d %d\n", *p2, *(p2 + 1), *(p2 + 2), *(p2 + 3));
}

大家这里会有疑惑的是

char buf[4] = {
   192, 168, 1, 2}; // 32位
unsigned int num = *(unsigned int *)buf;

第一行这里是定义了一个 32位的数组存放 ip的字符串

第二行定义了一 个 (int *) 类型的指针,并进行解引用, 就相当于是取了 4个字节的数据 . 也就是把32位字符数组的全部内容存放在了能存放32位数据的整型变量 num 中 .

(这里有疑惑的可以看我的指针的步长及意义(c语言基础))其中有讲到对不同指针解引用,会取出不同的地址

VS下常见指针类型解引用时取出的字节数分别为:

char *:1个字节(通常需要强转)

指针解引用时取出数据的字节数不同

VS下常见指针类型解引用时取出的字节数分别为:

char *:1个字节(通常需要强转)

int * :4个字节

2- IP地址转换函数

点分十进制串 转 为一个 32位 无符号数

早期(不用管)

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp,struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
char *inet_ntoa(struct in_addr in);
 in_addr_t inet_network(const char *cp);
  • inet_aton 转换网络主机地址(点分十进制)为网络字节序二进制值.

    1输入参数string包含ASCII表示的IP地址。

2 输出参数addr是将要用新的IP地址更新的结构。

inet_aton("127.0.0.1",&adr_inet.sin_addr)
  • inet_addr转换网络主机地址(点分十进制)为网络字节序二进制值,如果参数 char *cp 无效则返回-1(INADDR_NONE),但这个函数有个缺点:在处理地址为255.255.255.255时也返回-1,虽然它是一个有效地址,但inet_addr()无法处理这个地址。
in_addr_t inet_addr(const char *cp);

那inet_aton和inet_addr有什么区别呢?

inet_addr不支持255.255.255.255,inet_aton支持255.255.255.255

inet_ntoa() 和 inet_network 有什么区别?

inet_ntoa() 支持255.255.255.255  和  inet_network 不支持255.255.255.255
  • inet_ntoa()函数转换网络字节序地址->标准的点分十进制地址。该函数返回值指向保存点分十进制的字符串地址的指针,该字符串的空间为静态分配 的,所以在第二次调用这个函数时,意味着上一次调用并保存的结果将会被覆盖(重写)。so creazy!!!
    • 好了那就来证实一下,inet_ntoa()的静态返回值吧!!
char *add1,add2;
src.sin_addr.s_addr  =  inet_addr("192.168.1.123");
add1 =inet_ntoa(src.sin_addr);                  
src.sin_addr.s_addr = inet_addr("192.168.1.124");
add2 = inet_ntoa(src.sin_addr);
 
printf("a1:%s\n",add1);
printf("a2:%s\n",add2);
最终的printf结果是:
a1:192.168.1.124
a2:192.168.1.124

总结:

inet_aton计算出来的是网络字节序的二进制IP 支持255.255.255.255

inet_network计算出来的是主机字节序的二进制IP 不支持255.255.255.255

inet_addr计算出来的是网络字节序的二进制IP 不支持255.255.255.255

inet_ntoa计算出来的是主机字节序的二进制IP 支持255.255.255.255 静态覆盖问题

  • 均只能处理Pv4的ip地址
  • 均为不可重入函数
举例

inet_addr、inet_network、inet_aton

#include <stdio.h>  
#include <stdlib.h>  
#include <unistd.h>  
#include <string.h>  
#include <netinet/in.h>  
#include <sys/socket.h>  
#include <sys/types.h>  
  
int main()  
{
     
    char ip[] = "192.168.0.74";  
    long r1, r2, r3;  //long  
    struct in_addr addr;  
  
    r1 = inet_addr(ip); //返回网络字节序  
    if(-1 == r1){
     
        printf("inet_addr return -1/n");  
    }else{
     
        printf("inet_addr ip: %ld/n", r1);  
    }  
      
    r2 = inet_network(ip);    //返回主机字节序  
    if(-1 == r2){
     
        printf("inet_addr return -1/n");  
    }else{
     
        printf("inet_network ip: %ld/n", r2);   
        printf("inet_network ip: %ld/n", ntohl(r2));   //ntohl: 主机字节序 ——> 网络字节序  
    }  
      
    r3 = inet_aton(ip, &addr);  //返回网络字节序  
    if(0 == r3){
     
        printf("inet_aton return -1/n");  
    }else{
     
        printf("inet_aton ip: %ld/n", addr.s_addr);  
    }  
  
/*****  批量注释的一种方法  *****/  
#if 0    
    r3 = inet_aton(ip, addr);  
    if(0 == r3){
     
        printf("inet_aton return -1/n");  
    }else{
     
        printf("inet_aton ip: %ld/n", ntohl(addr.s_addr));  
    }  
#endif  
  
    return 0;  
}  
运行结果:

[work@db-testing-com06-vm3.db01.baidu.com net]$ gcc -W -o inet_addr inet_addr.c 
[work@db-testing-com06-vm3.db01.baidu.com net]$ ./inet_addr                     
inet_addr ip: 1241557184
inet_network ip: -1062731702
inet_network ip: 1241557184
inet_aton ip: 1241557184 

现在

#include <arpa/inet.h>
int inet_pton(int af,const char src,void *dst); //点分十进制串转字节序而且是  主机字节序转网络字节序
const char inet_ntop(int af,const void *src,char *dst,socklen_t size);// 网络字节序转主机字节序

其中 af 是 地址协议 ,AF_INET (ipv4) 和 AF_INET(ipv6)

src 是源 ip地址串 . dst 是万能引用类型, 也就是只要 能存放32位数的 变量就行

inet_ptoninet_ntop不仅可以转换IPv4in_addr,还可以转换IPv6in6_addr

  • 这样来看的话,我认为如果有需要最好是用inet_pton()、inet_ntop()代替inet_ntoa()、inet_addr().

inet_pton(AF_INET, cp, &src.sin_addr);

代替

src.sin_addr.s_addr = inet_addr(cp);


char str[INET_ADDRATRLEN];
ptr = inet_ntop(AF_INET, &src.sin_addr, str, sizeof(str));
代替
ptr = inet_ntoa(src.sin_addr);

与字节序转换函数相比:

  • uint32_t htonl(unin32_t host32bitvalue);
    参数是32bit的二进制数值,在转换地址时就是32位的主机字节序ip地址(经常用点分十进制)
    用法:

  • servaddr.sin_addr.s_addr=htonl(127.0.0.1);
    servaddr.sin_addr.s_addr=htonl(INADDR_ANY); // INADDR_ANY真实值为0.0.0.0
    
  • int inet_pton(int family,const char *strptr,void *addrptr);
    该函数完成两个功能:1.字符串->二进制数值 2.主机字节序->网络字节序(所以调用此函数后不需htonl了)
    第二个参数是ip地址字符串的指针

  • 用法:

- ```
  inet_pton(AF_INET,argv[1],&servaddr.sin_addr);
  第三个参数使用&servaddr.sin_addr.s_addr也可以通过
  ```

总结:数值型的ip地址转换用htonl,字符串类型的用inet_pton

例子(点分十进制串转成网络大端数据)

// 点分十进制串转成网络大端数据 (字符串和网络大端数据(二进制)的转换)
#include <stdio.h>
#include <arpa/inet.h>

void func()
{
   

    // todo 准备一个待转换的点分十进制
    char buf[] = "192.168.1.4";
    // todo 准备一个能存放32位网络数据的容器
    unsigned int num = 0;
    // inet_pton  //todo 将点分十进制串转成32位网络大端的数据
    if (1 == inet_pton(AF_INET, buf, &num)) // 转换成功返回1
    {
   
        printf("转换成功\n");
        // todo 每四位取出数据
        unsigned char *p2 = (unsigned char *)&num;
        printf("%d %d %d %d\n", *p2, *(p2 + 1), *(p2 + 2), *(p2 + 3));
    }
}
void func2()
{
   
    // todo 准备一个待转换的点分十进制
    char buf[] = "192.168.1.4";
    // todo 准备一个能存放32位网络数据的容器
    unsigned int num = 0;
    inet_pton(AF_INET, buf, &num);

    // todo  现在将大端网络数据num转成点分十进制传

    char ip[16] = {
   0};
    const char *p = inet_ntop(AF_INET, &num, ip, sizeof(ip)); // 返回值:存储点分制串数组首地址
    printf("点分十进制串为 %s\n", p);
}
int main()
{
   

    func();
    func2();
}

3 - 套接字(地址)结构体

1、通用套接字(地址)结构体类型(最初的套接字(地址)结构体)

 struct sockaddr
	{
   
		sa_family_t sa_family; //协议簇
		char sa_data[14]; //协议簇数据
	}

通用套接字结构体可以在不同的协议簇之间进行强制转化,Socket网络编程中几乎所有套接字API函数的形参都是通用套接字结构体struct sockaddr。(因为历史遗留)

  • 通用套接字结构体对编程的角度来说,设置很不方便,我们以以太网协议来说,当要设置端口号、IP地址等,那么我需要将端口号与IP地址进行数据组合绑定,然后赋值给该结构,是不能独立赋值。

  • 为解决上述问题,以太网协议中经常用到的是下述结构体,这样就可以给人以直观的方式去填充套接字结构体。

2- ipv4套接字结构体

struct sockaddr_in
	{
		u8 sin_len;
		u8 sin_family;
		u16 sin_port;
		struct in_addr sin_addr;
		char sin_zero[8]; 
	}
  • 结构体成员列表
结构体成员 参数含义 备注
u8 sin_len 结构体sockaddr_in的长度 一般大小为固定16字节
u8 sin_family 协议族类型 见下表
u16 sin_port 16位端口号 XXX
struct in_addr sin_addr 32位IP地址 INADDR_ANY //表示可以与任何主机通信
char sin_zero[8] //未使用 填充位,一般都设置为0

  • 协议簇列表

协议簇类型(sin_family) 参数含义
AF_INET 以太网/IPv4协议
AF_INET6 以太网/IPv6协议
AF_LOCAL Unix域协议/只在本机内通信的套接字
AF_ROUTE 路由套接口
AF_KEY 密钥套接口

***Note : *** 我们主要使用的是以太网,所以sin_family成员一般都为AF_INET ,有时候我们看到协议簇类型是PF_\* 而不是 AF\*,这是因为glibc的实现机制是posix,其实都是同一个东西。


存在问题:

  • Socket网络编程中几乎所有套接字API函数的形参都是通用套接字结构体struct sockaddr,而我们初始化传递的参数是以太网套接字结构体struct sockaddr类型,这样是否就存在类型不一致的问题?
Exzampp:
  // API函数: fun(struct sockaddr)
 //  用户实际调用: 
 int main()
 {
   
	 struct sockaddr_in;
	 fun(sockaddr_in);    //是否存在问题?
 }
	 

问题解答:

  • 上述操作完全可以,因为这两个结构体在内存上的大小完全一致都是16个字节,所以隐式的转换不存在其它问题。
  • struct sockaddr = struct sockaddr_in 。 (不存在问题)

但一般使用的时候都会强制转换一下,以便 struct sockaddr 形参 能接受 struct sockaddr_in 的实参

例子
    // todo 创建服务器socket地址结构体
    struct sockaddr_in serv_addr;
    // 端口
    serv_addr.sin_port = htonl(6500);
    // ip协议
    serv_addr.sin_family = AF_INET;
    // 绑定地址
  // 方式一
  // serv_addr.sin_addr.s_addr =htons(INADDR_ANY);//这个宏返回任何可用的ip地址(二进制类型0.0.0.0)
    int num;
    inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr.s_addr);

    // todo 建立和服务器的链接(这里强转)
    int ret = connect(cfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

3 - ipv6套接字结构体

struct sockaddr_in6定义在in6.h中;

struct in6_addr {
	union {
		__u8		u6_addr8[16];
		__be16		u6_addr16[8];
		__be32		u6_addr32[4];
	} in6_u;
#define s6_addr			in6_u.u6_addr8
#define s6_addr16		in6_u.u6_addr16
#define s6_addr32		in6_u.u6_addr32
};
 
struct sockaddr_in6 {
	unsigned short int	sin6_family;    /* AF_INET6 */
	__be16			sin6_port;      /* Transport layer port # */
	__be32			sin6_flowinfo;  /* IPv6 flow information */
	struct in6_addr		sin6_addr;      /* IPv6 address */
	__u32			sin6_scope_id;  /* scope id (new in RFC2553) */
};

4- 新的通用套接字地址结构

相对比于struct sockaddr,struct sockaddr_storage有如下区别:

(1)、struct sockaddr_storage结构足以容纳系统所支持的任何套接字地址结构;

(2)、struct sockaddr_storage结构满足最苛刻的字节对齐要求;

#define _K_SS_MAXSIZE	128	/* Implementation specific max size */
#define _K_SS_ALIGNSIZE	(__alignof__ (struct sockaddr *))
				/* Implementation specific desired alignment */
 
typedef unsigned short __kernel_sa_family_t;
 
struct __kernel_sockaddr_storage {
   
	__kernel_sa_family_t	ss_family;		/* address family */
	/* Following field(s) are implementation specific */
	char		__data[_K_SS_MAXSIZE - sizeof(unsigned short)];
				/* space to achieve desired size, */
				/* _SS_MAXSIZE value minus size of ss_family */
} __attribute__ ((aligned(_K_SS_ALIGNSIZE)));	/* force desired alignment */

5 套接字地址结构比较

​ 这里参考《UNIX套接字编程卷一》给出BSD实现下的各个套接字地址结构的比较,只作参考;




进入正式篇章

1、网络中进程之间如何通信?

本地的进程间通信(IPC)有很多种方式,但可以总结为下面4类:

  • 消息传递(管道、FIFO、消息队列)
  • 同步(互斥量、条件变量、读写锁、文件和写记录锁、信号量)
  • 共享内存(匿名的和具名的)
  • 远程过程调用(Solaris门和Sun RPC)

但这些都不是本文的主题!我们要讨论的是网络中进程之间如何通信?首要解决的问题是如何唯一标识一个进程,否则通信无从谈起!在本地可以通过进程PID来唯一标识一个进程,但是在网络中这是行不通的。其实TCP/IP协议族已经帮我们解决了这个问题,网络层的“ip地址”可以唯一标识网络中的主机,而传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。这样利用三元组(ip地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互。

使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字(socket)和UNIX System V的TLI(已经被淘汰),来实现网络进程之间的通信。就目前而言,几乎所有的应用程序都是采用socket,而现在又是网络时代,网络中进程通信是无处不在,这就是我为什么说“一切皆socket”。

2、什么是Socket?

上面我们已经知道网络中的进程是通过socket来通信的,那什么是socket呢?socket起源于Unix,而Unix/Linux基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。我的理解就是Socket就是该模式的一个实现,socket即是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭),这些函数我们在后面进行介绍。

3、socket的基本操作

既然socket是“open—write/read—close”模式的一种实现,那么socket就提供了这些操作对应的函数接口。下面以TCP为例,介绍几个基本的socket接口函数。

3.1、socket()函数

int socket(int domain, int type, int protocol);

socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而**socket()**用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。

正如可以给fopen的传入不同参数值,以打开不同的文件。创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:

  • domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET(IPV4)AF_INET6(IPV6)AF_LOCAL(或称AF_UNIXUnix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
  • type:指定socket类型。常用的socket类型有,SOCK_STREAM (tcp)、SOCK_DGRAM (udp)、SOCK_RAWSOCK_PACKETSOCK_SEQPACKET等等(socket的类型有哪些?)。
  • protocol:故名思意,就是指定协议。常用的协议有,IPPROTO_TCPIPPTOTO_UDPIPPROTO_SCTPIPPROTO_TIPC等,它们分别对应TCP传输协议UDP传输协议STCP传输协议TIPC传输协议(这个协议我将会单独开篇讨论!)。

注意:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。

当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。

3.2、bind()函数

正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

函数的三个参数分别为:

  • sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。

  • addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,如ipv4对应的是:

    struct sockaddr_in {
        sa_family_t    sin_family; 
        in_port_t      sin_port;   
        struct in_addr sin_addr;   
    };
    
    
    struct in_addr {
        uint32_t       s_addr;     
    };
    

    ipv6对应的是:

    struct sockaddr_in6 { 
        sa_family_t     sin6_family;    
        in_port_t       sin6_port;      
        uint32_t        sin6_flowinfo;  
        struct in6_addr sin6_addr;      
        uint32_t        sin6_scope_id;  
    };
    
    struct in6_addr { 
        unsigned char   s6_addr[16];    
    };
    

    Unix域对应的是:

    #define UNIX_PATH_MAX    108
    
    struct sockaddr_un { 
        sa_family_t sun_family;                
        char        sun_path[UNIX_PATH_MAX];   
    };
    
  • addrlen:对应的是地址的长度。

通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。

网络字节序与主机字节序

主机字节序就是我们平常说的大端和小端模式:不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。引用标准的Big-Endian和Little-Endian的定义如下:

a) Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。

b) Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。

网络字节序:4个字节的32 bit值以下面的次序传输:首先是0~7bit,其次8~15bit,然后16~23bit,最后是24~31bit。这种传输次序称作大端字节序。**由于TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种次序,因此它又称作网络字节序。**字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,一个字节的数据没有顺序的问题了。

所以: 在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序,而不要假定主机字节序跟网络字节序一样使用的是Big-Endian。由于 这个问题曾引发过血案!公司项目代码中由于存在这个问题,导致了很多莫名其妙的问题,所以请谨记对主机字节序不要做任何假定,务必将其转化为网络字节序再 赋给socket。

3.3、listen()、connect()函数

如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。

int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。

connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。

3.4、accept()函数

TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。

注意:accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。

3.5、read()、write()等函数

万事具备只欠东风,至此服务器与客户已经建立好连接了。可以调用网络I/O进行读写操作了,即实现了网咯中不同进程之间的通信!网络I/O操作有下面几组:

  • read()/write()
  • recv()/send()
  • readv()/writev()
  • recvmsg()/sendmsg()
  • recvfrom()/sendto()

我推荐使用recvmsg()/sendmsg()函数,这两个函数是最通用的I/O函数,实际上可以把上面的其它函数都替换成这两个函数。它们的声明如下:

       #include 

       ssize_t read(int fd, void *buf, size_t count);
       ssize_t write(int fd, const void *buf, size_t count);

       #include 
       #include 

       ssize_t send(int sockfd, const void *buf, size_t len, int flags);
       ssize_t recv(int sockfd, void *buf, size_t len, int flags);

       ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
                      const struct sockaddr *dest_addr, socklen_t addrlen);
       ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                        struct sockaddr *src_addr, socklen_t *addrlen);

       ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
       ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

read函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。如果错误为EINTR说明读是由中断引起的,如果是ECONNREST表示网络连接出了问题。

write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节 数。失败时返回-1,并设置errno变量。在网络程序中,当我们向套接字文件描述符写时有俩种可能。1)write的返回值大于0,表示写了部分或者是 全部的数据。2)返回的值小于0,此时出现了错误。我们要根据错误类型来处理。如果错误为EINTR表示在写的时候出现了中断错误。如果为EPIPE表示 网络连接出现了问题(对方已经关闭了连接)。

其它的我就不一一介绍这几对I/O函数了,具体参见man文档或者baidu、Google,下面的例子中将使用到send/recv。

3.6、close()函数

在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。

#include 
int close(int fd);

close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。

注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。

4 , Socket通信过程

Socket 保证了不同计算机之间的通信,也就是网络通信。对于网站,通信模型是服务器与客户端之间的通信。两端都建立了一个 Socket 对象,然后通过 Socket 对象对数据进行传输。通常服务器处于一个无限循环,等待客户端的连接。

下面是面向连接的 TCP 时序图:

客户端过程

客户端的过程比较简单,创建 Socket连接服务器,将 Socket 与远程主机连接(注意:只有 TCP 才有“连接”的概念,一些 Socket 比如 UDP、ICMP 和 ARP 没有“连接”的概念),发送数据读取响应数据,直到数据交换完毕,关闭连接,结束 TCP 对话。

  1. 调用 socket函数创建客户端 socket
  2. 调用 connect 函数尝试连接服务器
  3. 连接成功以后调用 send 或 recv 函数开始与服务器进行数据交流
  4. 通信结束后,调用 close 函数关闭侦听socket

代码描述

/**
 * TCP客户端通信基本流程
 * DJX2022 1.23
 * */
#include <iostream>
#include <cstdlib>
#include <cstring>

#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

static const char* data = "hello world";
static const ssize_t len = strlen(data);

int main(int argc, char* argv[])
{
   
    if(argc != 3)
    {
   
        std::cout << "Usage: " << argv[0] << " ip + port" << std::endl;
        return 0;
    }
// 1 创建连接套接字结构体
    int clientSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if(clientSock < 0)
    {
   
        std::cerr << "socket() error" << std::endl;
        exit(-1);
    }
// 2 初始化连接套接字结构体((这个结构体的参数是和服务器的监听套接字结构体的参数是一致的))
    sockaddr_in serverAddr;
    socklen_t serverAddrLen = sizeof(sockaddr_in);
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = inet_addr(argv[1]);
    serverAddr.sin_port = htons(atoi(argv[2]));
    int ret = connect(clientSock, (sockaddr*)&serverAddr, serverAddrLen);
    if(ret < 0)
    {
   
        std::cerr << "connect() error" << std::endl;
        close(clientSock);
        exit(-1);
    }

    char buf[BUFSIZ] = {
   0};
    ssize_t ret_send = 0;
    ssize_t ret_recv = 0;
    // 3 进行数据交互
    for(; ; )
    {
   
        ret_send = send(clientSock, data, len, 0);
        if(ret_send != len)
        {
   
            std::cerr << "send() data error" << std::endl;
            break;
        }
        memset(buf, 0x00, BUFSIZ);
        ret_recv = recv(clientSock, buf, BUFSIZ-1, 0);
        if(ret_recv > 0)
        {
   
            buf[ret_recv] = '\0';
            std::cout << "recv data successfully, data: " << buf << std::endl;
        }
        else if(ret_recv == 0)
        {
   
            std::cerr << "peer close connection" << std::endl;
            break;
        }
        else
        {
   
            std::cerr << "recv error" << std::endl;
            break;
        }
        sleep(3);
    }
//4 关闭客户端的连接
    close (clientSock);
    return 0;
}

./client 127.0.0.1 9092

服务端过程

服务端先初始化 Socket,建立流式套接字,与本机地址及端口进行绑定,然后通知 TCP,准备好接收连接,调用 accept() 阻塞,等待来自客户端的连接。如果这时客户端与服务器建立了连接,客户端发送数据请求,服务器接收请求并处理请求,然后把响应数据发送给客户端,客户端读取数据,直到数据交换完毕。最后关闭连接,交互结束。

  1. 调用 socket 函数创建 socket(侦听socket)
  2. 调用 bind 函数 将 socket绑定到某个ip和端口的二元组上
  3. 调用 listen 函数 开启侦听
  4. 当有客户端请求连接上来后,调用 accept 函数接受连接,产生一个新的 socket(客户端 socket)
  5. 基于新产生的 socket 调用 send 或 recv 函数开始与客户端进行数据交流
  6. 通信结束后,调用 close 函数关闭侦听 socket

代码描述

/**
 * TCP服务器通信基本流程
 * DJX2022 1.23
 */
#include <iostream>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

int main(int argc, char* argv[])
{
   
    // 0. 在启动服务器之前做一点准备工作
    // 服务器一般是要绑定 ip 和 port 的
    // 服务器的启动方式为 ./server ip port
    if(argc != 3)
    {
   
        std::cout << "Usage: " << argv[0] << " ip + port" << std::endl;
        return 0;
    }

    // 1. 创建监听套接字
    int listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if(listenSock < 0)
    {
   
        std::cerr << "socket() error" << std::endl;
        exit(-1);
    }

    // 2. 初始化服务器地址
    sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = inet_addr(argv[1]);
    serverAddr.sin_port = htons(atoi(argv[2]));
    socklen_t serverAddrLen = sizeof(sockaddr_in);
    int ret = bind(listenSock, (sockaddr*)&serverAddr, serverAddrLen);
    if(ret < 0)
    {
   
        std::cerr << "bind() error " << std::endl;
        close(listenSock);
        exit(-1);
    }

    // 3. 启动侦听
    ret = listen(listenSock, 5);
    if(ret < 0)
    {
   
        std::cerr << "listen() error" << std::endl;
        close(listenSock);
        exit(-1);
    }

    char buf[BUFSIZ] = {
   0};
    ssize_t ret_recv = 0;
    ssize_t ret_send = 0;
    for(; ;)
    {
   
        sockaddr_in clientAddr;
        socklen_t clientAddrLen = sizeof(sockaddr_in);

        // 4. 接收客户端连接
        int clientSock = accept(listenSock, (sockaddr*)&clientAddr, &clientAddrLen);
        if(clientSock < 0)
        {
   
            std::cerr << "accept() error" << std::endl;
            break;
        }

        for(; ; )
        {
   
            memset(buf, 0x00, BUFSIZ);
            // 5. 从客户端接收数据
            ret_recv = recv(clientSock, buf, BUFSIZ-1, 0);
            if(ret_recv > 0)
            {
   
                buf[ret_recv] = '\0';
                std::cout << "recv data from client, data: " << buf << std::endl;
                // 6. 将收到的数据原封不动的发给客户端
                ret_send = send(clientSock, buf, ret_recv, 0);
                if(ret_send < 0 || ret_send != ret_recv)
                {
   
                    std::cerr << "send data error." << std::endl;
                    break;
                }
            }
            else if(ret_recv == 0)
            {
   
                std::cout << "peer close connection." << std::endl;
                break;
            }
            else
            {
   
                std::cerr << "recv data error." << std::endl;
                break;
            }
        }
        close(clientSock);
    }

    // 7. 关闭侦听socket
    close(listenSock);
    return 0;
}

./server 127.0.0.1 9092

5 tcp报文的结构和功能介绍

TCP:传输、控制、协议。

  • TCP与UDP最大却别就在那个C上面,它充分实现了数据传输时各种控制功能。可以进行丢包重发控制,还可以对次序乱掉的数据包进行顺序控制,还能控制传输流量,这些是UDP中没有的。即T C P 提供一种面向连接的、可靠的字节流服务。
  • TCP是一中面向有链接的协议,只有在确认对端存在的时候,才会发送分数据,从而也可以控制通信流量的浪费。
  • 什么是可靠的传输:不丢包、不损坏、不乱序、不重复。
  • TCP通过校验和、序列号、确认应答、重发控制、连接管理以及窗口控制等机制来实现可靠传输。
  • 接收端查询就收数据TCP首部中的序号和数据长度。将自己下一步应该接受的序列号作为确认应答返送回去。就这样,通过序列号和确认应答,TCP实现可靠传输。

TCP 首部

TCP首部的数据结构如图所示:

TCP包首部

为了便于理解,忽略选项部分,固定首部通常为20个字节,将按作用分类分析。

端口号(port)

前4个字节来标识了发送方的端口号和接收方的端口号,即该数据包由谁发送,由谁接收。前2个字节标识源端口号,紧接着2个字节标识目的端口号。

即发送方:(11111111,1111111)2 = (65535)10,除去0~1023.

即接收方:(11111111,1111111)2 = (65535)10,除去0~1023.

序号(seq)

**TCP是面向字节流的。**在一个TCP连接中传送的字节流中的每一个字节都按顺序编号。整个要传送的字节流的起始序号必须在连接建立时设置。首部中的序号字段值则是指的是本报文段所发送的数据的第一个字节的序号。长度为4字节,序号是32bit的无符号数,序号到达2^32 - 1后又从0开始。

确认号(ack)

ack:确认序号,即确认字节的序号,更确切地说,是发送确认的一端所期望收到的下一个序号。

所谓的发送确认的一端*就是将确认信息发出的一端。比如第二次握手的S端就是发送确认的一端*。

确认序号为上次接收的最后一个字节序号加1.只有确认标志位(ACK)为1的时候,确认序号才有效。

数据偏移

也叫首部长度,占4个bit,它指出TCP报文段的数据起始处距离TCP报文段的起始处有多远。

image.png

TCP报文结构

由于首部中还有长度不确定的选项字段,因此数据偏移字段是必要的。

“首部长度”是4位二进制数,单位是32位字,能表示的最大十进制数字是15。

(1111)2=(15)10,即是15个32位,一个32位是4个字节,因此数据偏移的最大值是15*4=60个字节,这也是TCP首部的最大字节。因为固定首部的存在,数据偏移的值最小为20个字节,因此选项长度不能超过40字节(减去20个字节的固定首部)。

保留(reserve)

占6位,保留为今后使用,但目前应置为0。

紧急URG(urgent)

当URG=1时,表明紧急指针字段有效。

它告诉系统此报文段中有紧急数据,应尽快发送(相当于高优先级的数据),而不要按原来的排队顺序来传送。

例如,已经发送了很长的一个程序要在远地的主机上运行。但后来发现了一些问题,需要取消该程序的运行,因此用户从键盘发出中断命令。如果不使用紧急数据,那么这两个字符将存储在接收TCP的缓存末尾。只有在所有的数据被处理完毕后这两个字符才被交付接收方的应用进程。这样做就浪费了很多时间。

当URG置为1时,应用进程就告诉TCP有紧急数据要传送。于是TCP就把紧急数据插入到本报文段数据的最前面,而在紧急数据后面的数据仍然是普通数据。这时要与首部中紧急指针(Urgent Pointer)字段配合使用。

确认ACK(acknowledgment)

仅当ACK = 1时确认号字段才有效,当ACK = 0时确认号无效。TCP规定,在连接建立后所有的传送的报文段都必须把ACK置为1。

推送 PSH(push)

当两个应用进程进行交互式的通信时,有时在一端的应用进程希望在键入一个命令后立即就能收到对方的响应。在这种情况下,TCP就可以使用推送(push)操作。发送方TCP把PSH置为1,并立即创建一个报文段发送出去。接收方TCP收到PSH=1的报文段,就尽快地(即“推送”向前)交付接收应用进程。而不用再等到整个缓存都填满了后再向上交付。

复位RST(reset)

当RST=1时,表明TCP连接中出现了严重错误(如由于主机崩溃或其他原因),必须释放连接,然后再重新建立传输连接。RST置为1还用来拒绝一个非法的报文段或拒绝打开一个连接。

同步SYN(synchronization)

在连接建立时用来同步序号。当SYN=1ACK=0时,表明这是一个连接请求报文段。对方若同意建立连接,则应在响应的报文段中使SYN=1和ACK=1。

因此SYN=1就表示这是一个连接请求或连接接受报文。

终止FIN(finis,意思是“完”“终”)

用来释放一个连接。当FIN=1时,表明此报文段的发送发的数据已发送完毕,并要求释放运输连接。

窗口

占2字节。窗口值是(0,216 -1)之间的整数。

窗口指的是发送本报文段的一方的接受窗口(而不是自己的发送窗口),窗口大小是给对方用的。

窗口值告诉对方:从本报文段首部中的确认号算起,接收方目前允许对方一次发送的数据量(以字节为单位)。

之所以要有这个限制,是因为接收方的数据缓存空间是有限的。

总之,窗口值作为接收方让发送方设置其发送窗口的依据。

例如,A发送了一个报文段,其确认号是3000,窗口字段是1000.这就是告诉对方B:“从3000算起,A接收缓存空间还可接受1000个字节数据,字节序号是3000-3999”,可以想象到河道的阀门。

总之:窗口字段明确指出了现在允许对方发送的数据量。窗口值经常在动态变化。

检验和

占2字节。检验和字段检验的范围包括首部和数据这两部分。和UDP用户数据报一样,在计算检验和时,要在TCP报文段的前面加上12字节的伪首部。伪首部的格式和UDP用户数据报的伪首部一样。但应把伪首部第4个字段中的17改为6(TCP的协议号是6);把第5字段中的UDP中的长度改为TCP长度。接收方收到此报文段后,仍要加上这个伪首部来计算检验和。若使用TPv6,则相应的伪首部也要改变。

紧急指针

占2字节。紧急指针仅在URG=1时才有意义,它指出本报文段中的紧急数据的字节数(紧急数据结束后就是普通数据) 。因此,在紧急指针指出了紧急数据的末尾在报文段中的位置。当所有紧急数据都处理完时,TCP就告诉应用程序恢复到正常操作。值得注意的是,即使窗口为0时也可以发送紧急数据。

选项

长度可变,最长可达40个字节。当没有使用“选项”时,TCP的首部长度是20字节。

最大报文段长度

最大报文段长度(MSS:Maximum Segment Size)表示TCP传往另一端的最大块数据的长度。当一个连接建立时,连接的双方都要通告各自的MSS。

当建立一个连接时,每一方都有用于通告它期望接收的MSS选项(MSS选项只能出现在SYN报文段中),如果一方不接收来自另一方的MSS值,则MSS就定为默认值536字节(这个默认值允许20字节的IP首部和20字节的TCP首部以适合576字节IP数据报) 。

为什么要规定一个最大报文长度MSS呢?

这并不是考虑接受方的接收缓存可能存放不下TCP报文段中的数据。实际上,MSS与接收窗口值没有关系。

我们知道,TCP报文段的数据部分,至少要加上40字节的首部(TCP首部20字节IP首部20字节,这里还没有考虑首部中的可选部分)才能组装成一个IP数据报

若选择较小的MSS长度,网络的利用率就降低。设想在极端情况下,当TCP报文段只含有1字节的数据时,在IP层传输的数据报的开销至少有40字节(包括TCP报文段的首部和IP数据报的首部)。这样,对网络的利用率就不会超过1/41。到了数据链路层还要加上一些开销。但反过来,若TCP报文段非常长,那么在IP层传输时就有可能要分解成多个短数据报片。在终点要把收到的各个短数据报片组成成原来的TCP报文段,当传输出错时还要进行重传,这些也都会使开销增大。

因此,MSS应尽可能大些,只要在IP层传输时不需要分片就行。

由于IP数据报所经历的路径是动态变化的,因此在这条路径上确定的不需要的分片的MSS,如果改走另一条路径就可能需要进行分片。因此最佳的MSS是很难确定的。在连接过程中,双方都把自己能够支持的MSS写入这一字段,以后就按照这个数值传输数据,两个传送方向可以有不同的MSS值。若主机未填写这一项,则MSS的默认值是536字节长。因此,所有在互联网上的主机都应该接受的报文段长度是536+20(固定首部长度)=556字节

后来又增加了几个选项如窗口扩大选项、时间戳选项等。

窗口扩大选项

窗口扩大选项是为了扩大窗口。

我们知道,TCP首部中窗口字段长度是16位,因此最大的窗口大小为64K字节。虽然这对早期的网络是足够用的,但对于包含卫星信道的网络,传播时延和宽带都很大,要获得高吞吐量需要更大的窗口大小。

窗口扩大选项占3字节,其中有一个字节表示移位值S。新的窗口值等于TCP首部中的窗口位数从16增大到(16+S)。移位值允许使用的最大值是14,相当于窗口最大值增大到2(16+14)-1=230-1。

窗口扩大选项可以在双方初始建立TCP连接时进行协商。如果连接的某一端实现了窗口扩大,当它不再需要扩大其窗口时,可发送S=0选项,使窗口大小回到16。

时间戳选项

时间戳选项占10字节,其中最主要的字段是时间戳字段(4字节)和时间戳回送回答字段(4字节)。时间戳选项有以下两个概念:

第一、 用来计算往返时间RTT。发送方在发送报文段时把当前时钟的时间值放入时间戳字段,接收方在确认该报文段时把时间戳字段复制到时间戳回送回答字段。因此,发送方在收到确认报文后,可以准确地计算出RTT来。

第二、 用于处理TCP序号超过232 的情况,这又称为防止序号绕回PAWS。我们知道,TCP报文段的序号只有32位,而每增加232 个序号就会重复使用原来用过的序号。当使用高速网络时,在一次TCP连接的数据传送中序号很可能被重复使用。例如,当使用1.5Mbit/s的速度发送报文段时,序号重复要6小时以上。但若用2.5Gbit/s的速率发送报文段,则不到14秒钟序号就会重复。为了使接收方能够把新的报文段和迟到很久的报文段区分开,则可以在报文段中加上这种时间戳。

6 TCP协议中的seq/ack序号是如何变化的?

交互图一

这里提供了截取出来的一次client端和server端TCP包的交互过程.

再开始分析之前,还需要论述一下seq、ack表示什么意思,应该以什么样的角度去理解这两个序列号。

  • 第一点 sequence number:表示的是我方(发送方)这边,这个packet的数据部分的第一位应该在整个data stream中所在的位置。(注意这里使用的是“应该”。因为对于没有数据的传输,如ACK,虽然它有一个seq,但是这次传输在整个data stream中是不占位置的。所以下一个实际有数据的传输,会依旧从上一次发送ACK的数据包的seq开始

    • 比如No3 到No4 ; No3并没有数据传输,而且seq为1, 在下一个实际有数据传输的阶段No4的时候,并没有在No3 seq=1的基础上 ,进行累加数据长度,而是依旧从上一次发送ACK的数据包的seq开始.

      • 同理 从 No【7】开始,client端这边就只负责做响应,发送ACK数据包,而并没有实际的数据发送到server端。所以,从【7】开始,所有的ACK数据包的seq都是相同的726,因为ACK不像SYN/FIN可以让seq增加,所以发送再多的ACK包都只能让seq原地踏步.
  • 第二点acknowledge number:表示的是期望的对方(接收方)的下一次sequence number是多少。

  • 第三点注意,SYN/FIN的传输虽然没有data,但是会让下一次传输的packet seq增加一,但是,ACK的传输,不会让下一次的传输packet加一

    • 简而言之,接收方反馈的ACK之所以加1,是因为发送方包含了SYN标志位或FIN标志位。

      也就是说,只要发送方包含了 SYN标志位或FIN标志位,即使没有包含数据,接收方也得认为 发送方消耗掉了一个序号。

seq和ack号存在于TCP报文段的首部中,seq是序号,ack是确认号,大小均为4字节。

seq:占 4 字节,序号范围[0,2^32-1],序号增加到 2^32-1 后,下个序号又回到 0。TCP 是面向字节流的,通过 TCP 传送的字节流中的每个字节都按顺序编号,而报头中的序号字段值则指的是本报文段数据的第一个字节的序号

ack:占 4 字节,期望收到对方下个报文段的第一个数据字节的序号。

交互图一分析

No1.(client)

seq:client端第一次发送packet,即:first-way handshake。所以按照上面的准则,它的数据应该从第一个开始,也即是第0位开始,所以seq为0。

ack:而server端之前并未发送过数据,所以期望的是server端回传时的packet的seq应该从第一个开始,即是第0位开始,所以ack为0。

No2.(server)

seq:server端第一次发送packet,即:second-way handshake。所以,这个packet的seq为0。

ack:由于在【1】中接收到的是client端的SYN数据包,且它的seq为0,所以client端会让它自己的seq增加1。由此可预计(expect),client端的下一次packet传输时,它的seq是1(0增加1)。所以,ACK为1。

No3、(client)

  • seq:third-way handshake。上一次发送时为【1】,【1】中seq为0且为SYN数据包,所以这一次的seq为1(0增加1)。
  • ack:上次接收到时为【2】,【2】中seq为0,且为SYN数据包(虽然在flag上同时设定为SYN/ACK,但只要flag是SYN,就会驱使seq加一),所以可预计,server端下一次seq为1(0增加1)。

No4、(client)

  • seq:上一次发送时为【1】,【1】中seq为0且为SYN数据包,所以这一次的seq为1(0增加1)。
  • ack:上次接收到时为【2】,【2】中seq为0,且为SYN数据包,所以可预计,server端下一次seq为1(0增加1)。

No5、(server)

  • seq:上一次发送时为【2】,【2】中seq为0,且为SYN数据包,所以这一次的seq为1(0增加1)。
  • ack:上一次接收时为【4】,【4】中的seq为1,数据包的长度为725,所以可以预计,下一次client端的seq为726(1+725)。

No6、(server)

  • seq:上一次发送时为【5】,【5】中seq为1,但【5】为ACK数据包,所以数据长度为0且不会驱使seq加1,所以这一次的seq为1(1+0)。
  • ack:上一次接收时为【4】,【4】中的seq为1,数据包的长度为725,所以可以预计,下一次client端的seq为726(1+725)。

No7、(client)

  • seq:上一次发送时为【4】,【4】中seq为1,数据包长度为725,所以这一次的seq为726(1+725)。
  • ack:上一次接收时为【6】,【6】中seq为1,且数据长度为1448,所以可以预计,下一次server端的seq为1449(1+1448)。

No8、(server)

  • seq:上一次发送时为【6】,【6】中seq为1,数据包长度为1448,所以这一次的seq为1449(1+1448)。
  • ack:上一次接收时为【7】,【7】中seq为726,数据包为ACK、即数据为0,所以可以预计,下一次client端的seq为726(726+0)。

No9、(client)

  • seq:上一次发送时为【7】,【7】中seq为726,数据包为ACK、即长度为0, 所以这一次seq为726(726+0)。
  • ack:上一次接收时为【8】,【8】中seq为1449,数据包长度为1448,所以可以预计,下一次server端的seq为2897(1449+1448)。

No10、(server)

  • seq:上一次发送时为【8】,【8】中seq为1449,且数据包长度为1448,所以这一次seq为2897(1449+1448)。
  • ack:上一次接收时为【9】,【9】中seq为726,数据包为ACK、即数据为0,所以可以预计,下一次client端的seq为726(726+0)。

No11、(client)

  • seq:上一次发送时为【9】,【9】中seq为726,且数据包长度为0,所以这一次seq为726(726+0)。
  • ack:上一次接收时为【10】,【10】中seq为2897,数据包长度为1448、所以可以预计,下一次client端的seq为4345(1448+2897)。

No12、(server)

  • seq:上一次发送时为【10】,【10】中seq为2897,且数据包长度为1448,所以这一次seq为4345(2897+1448)。
  • ack:上一次接收时为【11】,【11】中seq为726,数据包为ACK、即数据为0,所以可以预计,下一次client端的seq为726(726+0)。

No13、(client)

  • seq:上一次发送时为【11】,【11】中seq为726,且数据包长度为0,所以这一次seq为726(726+0)。
  • ack:上一次接收时为【12】,【12】中seq为4345,数据包长度为1448、所以可以预计,下一次client端的seq为5763(1448+4345)。

No14、(server)

  • seq:上一次发送时为【12】,【12】中seq为4345,且数据包长度为1448,所以这一次seq为5763(4345+1448)。
  • ack:上一次接收时为【13】,【13】中seq为726,数据包为ACK、即数据为0,所以可以预计,下一次client端的seq为726(726+0)。

No15、(client)

  • seq:上一次发送时为【13】,【13】中seq为726,且数据包长度为0,所以这一次seq为726(726+0)。
  • ack:上一次接收时为【14】,【14】中seq为5793,数据包长度为1448、所以可以预计,下一次client端的seq为7241(1448+5793)。

No16、(server)

  • seq:上一次发送时为【14】,【14】中seq为5793,且数据包长度为1448,所以这一次seq为7241(5793+1448)。
  • ack:上一次接收时为【15】,【15】中seq为726,数据包为ACK、即数据为0,所以可以预计,下一次client端的seq为726(726+0)。

No17、(client)

  • seq:上一次发送时为【15】,【15】中seq为726,且数据包长度为0,所以这一次seq为726(726+0)。
  • ack:上一次接收时为【16】,【16】中seq为7241,数据包长度为1448、所以可以预计,下一次client端的seq为8689(1448+7241)。

可以看到的是,从【7】开始,client端这边就只负责做响应,发送ACK数据包,而并没有实际的数据发送到server端。所以,从【7】开始,所有的ACK数据包的seq都是相同的726,因为ACK不像SYN/FIN可以让seq增加,所以发送再多的ACK包都只能让seq原地踏步。

从【7】开始, 【9】【11】【13】【15】【17】 (client蓝色背景的)由于没有携带任何数据,实际上Seq =726是没有意义。但由于这个固定字段总得放个值进去,所以放个Next Sequence Number到这里是最合适的。也就是说这次发送没有消耗掉序号。
有意义的只有ACK ,这样就告诉了服务器:我这边已经成功接受到了 8688为止的数据 (ACK-1) (减一是因为syn消耗了一个序号,确不携带数据)


交互图二

可知左侧为 client ,右侧为 server

交互图二分析

就像交互图一的从 【7】 【9】【11】【13】【15】【17】 .client端这边就只负责做响应,发送ACK数据包,而并没有实际的数据发送到server端(sep的值没有变化)。

交互图二中 [1] [2] [3] 是三次握手 . [4] [5] [6] [7] [8] [9] 是 只有server端 负责做响应 (ack的值始终没变化,因为server端在此期间没有数据发送过来), 而client持续发送 seq 数据包(1024个字节)到server,

[10] [11] 则是server端 连续向client发送 (ack的值也没有变化,因为 client再此期间,没有数据发送过来).此时 [10] 的seq是 8001

[11] 是 8001 +发送的字节长度(0)

[12] [13] 是client端 连续向 server 端 发送数据(1024个字节 ) (ack的值始终没有变化, 因为server端在此期间没有数据发送过来)

[13] 同时发送了 FIN 占一个序号

序列号字段 7169(已经加上syn) + 1024 (数据长度) = 8193. 所以下一次 再发送就 发送 第8194 序号

[14] [15] [16] 是server端 连续向client发送 (ack的值也没有变化,因为 client再此期间,没有数据发送过来). 此时ack一直都是 8194

[17] server 端发送 了 0 字节数据. 但 FIN 占一个字节 . 所以 server端 下次 就要从第 8002号序列号开始 发送

[18] client 的seq为 8002

例子

例如,B收到了A发送过来的报文,其序列号字段是501,而数据长度是200字节,这表明B正确的收到了A发送的到序号700为止的数据。因此,B期望收到A的下一个数据序号是701,于是B在发送给A的确认报文段中把确认号置为701;

图文解释如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3Tm8EX3Q-1676727291927)(C:\Users\User\AppData\Roaming\Typora\typora-user-images\image-20230218201158692.png)]

这里的序列号字段值 501 是指总共字节为 501 个 ; 而 seq:占 4 字节,序号范围[0,2^32-1]

所以是 0到 500 . (第0号序列号,到第500号序列号)

序号 0到700就是701个数据,也就是第700序号(701个字节)为止 . 下一个数据是 第701号序号(702个字节)

注意图中的黄色方框是 syn 所占的字节(它会消耗掉一个序列号0)

总结

Ack号 = Seq号 + 传递的字节数 + 1

seq和ack号存在于TCP报文段的首部中,seq是序号,ack是确认号,大小均为4字节。

seq:占 4 字节,序号范围[0,2^32-1],序号增加到 2^32-1 后,下个序号又回到 0。TCP 是面向字节流的,通过 TCP 传送的字节流中的每个字节都按顺序编号,而报头中的序号字段值则指的是本报文段数据的第一个字节的序号

ack:占 4 字节,期望收到对方下个报文段的第一个数据字节的序号。

7, socket中TCP连接释放详解

三次握手

使用 connect() 建立连接时,客户端和服务器端会相互发送三个数据包,请看下图:

TCP建立连接时要传输三个数据包,俗称三次握手(Three-way Handshaking)。可以形象的比喻为下面的对话:

  • [Shake 1] 套接字A:“你好,套接字B,我这里有数据要传送给你,建立连接吧。”
  • [Shake 2] 套接字B:“好的,我这边已准备就绪。”
  • [Shake 3] 套接字A:“谢谢你受理我的请求。”

客户端调用 socket() 函数创建套接字后,因为没有建立连接,所以套接字处于CLOSED状态;服务器端调用 listen() 函数后,套接字进入LISTEN状态,开始监听客户端请求。

这个时候,客户端开始发起请求:

  • \1) 当客户端调用 connect() 函数后,TCP协议会组建一个数据包,并设置 SYN 标志位,表示该数据包是用来建立同步连接的。同时生成一个随机数字 1000,填充“序号(Seq)”字段,表示该数据包的序号。完成这些工作,开始向服务器端发送数据包,客户端就进入了SYN-SEND状态。

  • \2) 服务器端收到数据包,检测到已经设置了 SYN 标志位,就知道这是客户端发来的建立连接的“请求包”。服务器端也会组建一个数据包,并设置 SYN 和 ACK 标志位,SYN 表示该数据包用来建立连接,ACK 用来确认收到了刚才客户端发送的数据包。

    • 服务器生成一个随机数 2000,填充“序号(Seq)”字段。2000 和客户端数据包没有关系。

    • 服务器将客户端数据包序号(1000)加1,得到1001,并用这个数字填充“确认号(Ack)”字段。

    • 服务器将数据包发出,进入SYN-RECV状态。

  • \3) 客户端收到数据包,检测到已经设置了 SYN 和 ACK 标志位,就知道这是服务器发来的“确认包”。客户端会检测“确认号(Ack)”字段,看它的值是否为 1000+1,如果是就说明连接建立成功。

    • 接下来,客户端会继续组建数据包,并设置 ACK 标志位,表示客户端正确接收了服务器发来的“确认包”。同时,将刚才服务器发来的数据包序号(2000)加1,得到 2001,并用这个数字来填充“确认号(Ack)”字段。

    • 客户端将数据包发出,进入ESTABLISED状态,表示连接已经成功建立。

  • \4) 服务器端收到数据包,检测到已经设置了 ACK 标志位,就知道这是客户端发来的“确认包”。服务器会检测“确认号(Ack)”字段,看它的值是否为 2000+1,如果是就说明连接建立成功,服务器进入ESTABLISED状态。

  • 至此,客户端和服务器都进入了ESTABLISED状态,连接建立成功,接下来就可以收发数据了。

最后的说明

三次握手的关键是要确认对方收到了自己的数据包,这个目标就是通过“确认号(Ack)”字段实现的。计算机会记录下自己发送的数据包序号 Seq,待收到对方的数据包后,检测“确认号(Ack)”字段,看Ack = Seq + 1是否成立,如果成立说明对方正确收到了自己的数据包。

数据传输

建立连接后,两台主机就可以相互传输数据了。如下图所示:

上图给出了主机A分2次(分2个数据包)向主机B传递200字节的过程。首先,主机A通过1个数据包发送100个字节的数据,数据包的 Seq 号设置为 1200。主机B为了确认这一点,向主机A发送 ACK 包,并将 Ack 号设置为 1301。

为了保证数据准确到达,目标机器在收到数据包(包括SYN包、FIN包、普通数据包等)包后必须立即回传ACK包,这样发送方才能确认数据传输成功。

此时 Ack 号为 1301 而不是 1201,原因在于 Ack 号的增量为传输的数据字节数。假设每次 Ack 号不加传输的字节数,这样虽然可以确认数据包的传输,但无法明确100字节全部正确传递还是丢失了一部分,比如只传递了80字节。因此按如下的公式确认 Ack 号:

Ack号 = Seq号 + 传递的字节数 + 1

与三次握手协议相同,最后加 1 是为了告诉对方要传递的 Seq 号。

下面分析传输过程中数据包丢失的情况,如下图所示:

上图表示通过 Seq 1301 数据包向主机B传递100字节的数据,但中间发生了错误,主机B未收到。经过一段时间后,主机A仍未收到对于 Seq 1301 的ACK确认,因此尝试重传数据。

为了完成数据包的重传,TCP套接字每次发送数据包时都会启动定时器,如果在一定时间内没有收到目标机器传回的 ACK 包,那么定时器超时,数据包会重传。

上图演示的是数据包丢失的情况,也会有 ACK 包丢失的情况,一样会重传。

重传超时时间(RTO, Retransmission Time Out)

这个值太大了会导致不必要的等待,太小会导致不必要的重传,理论上最好是网络 RTT 时间,但又受制于网络距离与瞬态时延变化,所以实际上使用自适应的动态算法(例如 Jacobson 算法和 Karn 算法等)来确定超时时间。

往返时间(RTT,Round-Trip Time)表示从发送端发送数据开始,到发送端收到来自接收端的 ACK 确认包(接收端收到数据后便立即确认),总共经历的时延。

重传次数

TCP数据包重传次数根据系统设置的不同而有所区别。有些系统,一个数据包只会被重传3次,如果重传3次后还未收到该数据包的 ACK 确认,就不再尝试重传。但有些要求很高的业务系统,会不断地重传丢失的数据包,以尽最大可能保证业务数据的正常交互。

最后需要说明的是,发送端只有在收到对方的 ACK 确认包后,才会清空输出缓冲区中的数据。

四次挥手

建立连接需要三次握手,断开连接需要四次握手,可以形象的比喻为下面的对话:

  • [Shake 1] 套接字A:“任务处理完毕,我希望断开连接。”
  • [Shake 2] 套接字B:“哦,是吗?请稍等,我准备一下。”
  • 等待片刻后……
  • [Shake 3] 套接字B:“我准备好了,可以断开连接了。”
  • [Shake 4] 套接字A:“好的,谢谢合作。”

下图演示了客户端主动断开连接的场景:

建立连接后,客户端和服务器都处于ESTABLISED状态。这时,客户端发起断开连接的请求:

  • \1) 客户端调用 close() 函数后,向服务器发送 FIN 数据包,进入FIN_WAIT_1状态。FIN 是 Finish 的缩写,表示完成任务需要断开连接。

  • \2) 服务器收到数据包后,检测到设置了 FIN 标志位,知道要断开连接,于是向客户端发送“确认包”,进入CLOSE_WAIT状态。

    • 注意:服务器收到请求后并不是立即断开连接,而是先向客户端发送“确认包”,告诉它我知道了,我需要准备一下才能断开连接。
  • \3) 客户端收到“确认包”后进入FIN_WAIT_2状态,等待服务器准备完毕后再次发送数据包。

  • \4) 等待片刻后,服务器准备完毕,可以断开连接,于是再主动向客户端发送 FIN 包,告诉它我准备好了,断开连接吧。然后进入LAST_ACK状态。

  • \5) 客户端收到服务器的 FIN 包后,再向服务器发送 ACK 包,告诉它你断开连接吧。然后进入TIME_WAIT状态。

  • \6) 服务器收到客户端的 ACK 包后,就断开连接,关闭套接字,进入CLOSED状态。

关于 TIME_WAIT 状态的说明

客户端最后一次发送 ACK包后进入 TIME_WAIT 状态,而不是直接进入 CLOSED 状态关闭连接,这是为什么呢?

TCP 是面向连接的传输方式,必须保证数据能够正确到达目标机器,不能丢失或出错,而网络是不稳定的,随时可能会毁坏数据,所以机器A每次向机器B发送数据包后,都要求机器B”确认“,回传ACK包,告诉机器A我收到了,这样机器A才能知道数据传送成功了。如果机器B没有回传ACK包,机器A会重新发送,直到机器B回传ACK包。

客户端最后一次向服务器回传ACK包时,有可能会因为网络问题导致服务器收不到,服务器会再次发送 FIN 包,如果这时客户端完全关闭了连接,那么服务器无论如何也收不到ACK包了,所以客户端需要等待片刻、确认对方收到ACK包后才能进入CLOSED状态。那么,要等待多久呢?

数据包在网络中是有生存时间的,超过这个时间还未到达目标主机就会被丢弃,并通知源主机。这称为报文最大生存时间(MSL,Maximum Segment Lifetime)。TIME_WAIT 要等待 2MSL 才会进入 CLOSED 状态。ACK 包到达服务器需要 MSL 时间,服务器重传 FIN 包也需要 MSL 时间,2MSL 是数据包往返的最大时间,如果 2MSL 后还未收到服务器重传的 FIN 包,就说明服务器已经收到了 ACK 包。

8 补充 : 套接字格式

流格式套接字(SOCK_STREAM)

流格式套接字(Stream Sockets)也叫“面向连接的套接字”,是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。

其特点:

  • 数据在传输过程中不会消失;

    数据是按照顺序传输的;

    数据的发送和接收不是同步的(有的教程也称“不存在数据边界”)。

可以将 SOCK_STREAM 比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失;同时,较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按照顺序传递的。

为什么流格式套接字可以达到高质量的数据传输呢?这是因为它使用了 TCP 协议(The Transmission Control Protocol,传输控制协议),TCP 协议会控制你的数据按照顺序到达并且没有错误。

你也许见过 TCP,是因为你经常听说“TCP/IP”。TCP 用来确保数据的正确性,IP(Internet Protocol,网络协议)用来控制数据如何从源头到达目的地,也就是常说的“路由”。

那么,“数据的发送和接收不同步”该如何理解呢?

假设传送带传送的是水果,接收者需要凑齐 100 个后才能装袋,但是传送带可能把这 100 个水果分批传送,比如第一批传送 20 个,第二批传送 50 个,第三批传送 30 个。接收者不需要和传送带保持同步,只要根据自己的节奏来装袋即可,不用管传送带传送了几批,也不用每到一批就装袋一次,可以等到凑够了 100 个水果再装袋。

流格式套接字的内部有一个缓冲区(也就是字符数组),通过 socket 传输的数据将保存到这个缓冲区。接收端在收到数据后并不一定立即读取,只要数据不超过缓冲区的容量,接收端有可能在缓冲区被填满以后一次性地读取,也可能分成好几次读取。

也就是说,不管数据分几次传送过来,接收端只需要根据自己的要求读取,不用非得在数据到达时立即读取。传送端有自己的节奏,接收端也有自己的节奏,它们是不一致的。

流格式套接字有什么实际的应用场景吗?浏览器所使用的 http 协议就基于面向连接的套接字,因为必须要确保数据准确无误,否则加载的 HTML 将无法解析。

数据报格式套接字(SOCK_DGRAM)

数据报格式套接字(Datagram Sockets)也叫“无连接的套接字”。计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。

因为数据报套接字所做的校验工作少,所以在传输效率方面比流格式套接字要高。

  • 有以下特征:

    强调快速传输而非传输顺序;

    传输的数据可能丢失也可能损毁;

    限制每次传输的数据大小;

    数据的发送和接收是同步的

众所周知,速度是快递行业的生命。用摩托车发往同一地点的两件包裹无需保证顺序,只要以最快的速度交给客户就行。这种方式存在损坏或丢失的风险,而且包裹大小有一定限制。因此,想要传递大量包裹,就得分配发送。

另外,用两辆摩托车分别发送两件包裹,那么接收者也需要分两次接收,所以“数据的发送和接收是同步的”;换句话说,接收次数应该和发送次数相同。

总之,数据报套接字是一种不可靠的、不按顺序传递的、以追求速度为目的的套接字。

数据报套接字也使用 IP 协议作路由,但是它不使用 TCP 协议,而是使用 UDP 协议(User Datagram Protocol,用户数据报协议)。

QQ 视频聊天和语音聊天就使用 SOCK_DGRAM 来传输数据,因为首先要保证通信的效率,尽量减小延迟,而数据的正确性是次要的,即使丢失很小的一部分数据,视频和音频也可以正常解析,最多出现噪点或杂音,不会对通信质量有实质的影响。

注意:SOCK_DGRAM 没有想象中的糟糕,不会频繁的丢失数据,数据错误只是小概率事件。

: 套接字格式

流格式套接字(SOCK_STREAM)

流格式套接字(Stream Sockets)也叫“面向连接的套接字”,是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。

其特点:

  • 数据在传输过程中不会消失;

    数据是按照顺序传输的;

    数据的发送和接收不是同步的(有的教程也称“不存在数据边界”)。

可以将 SOCK_STREAM 比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失;同时,较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按照顺序传递的。

为什么流格式套接字可以达到高质量的数据传输呢?这是因为它使用了 TCP 协议(The Transmission Control Protocol,传输控制协议),TCP 协议会控制你的数据按照顺序到达并且没有错误。

你也许见过 TCP,是因为你经常听说“TCP/IP”。TCP 用来确保数据的正确性,IP(Internet Protocol,网络协议)用来控制数据如何从源头到达目的地,也就是常说的“路由”。

那么,“数据的发送和接收不同步”该如何理解呢?

假设传送带传送的是水果,接收者需要凑齐 100 个后才能装袋,但是传送带可能把这 100 个水果分批传送,比如第一批传送 20 个,第二批传送 50 个,第三批传送 30 个。接收者不需要和传送带保持同步,只要根据自己的节奏来装袋即可,不用管传送带传送了几批,也不用每到一批就装袋一次,可以等到凑够了 100 个水果再装袋。

流格式套接字的内部有一个缓冲区(也就是字符数组),通过 socket 传输的数据将保存到这个缓冲区。接收端在收到数据后并不一定立即读取,只要数据不超过缓冲区的容量,接收端有可能在缓冲区被填满以后一次性地读取,也可能分成好几次读取。

也就是说,不管数据分几次传送过来,接收端只需要根据自己的要求读取,不用非得在数据到达时立即读取。传送端有自己的节奏,接收端也有自己的节奏,它们是不一致的。

流格式套接字有什么实际的应用场景吗?浏览器所使用的 http 协议就基于面向连接的套接字,因为必须要确保数据准确无误,否则加载的 HTML 将无法解析。

数据报格式套接字(SOCK_DGRAM)

数据报格式套接字(Datagram Sockets)也叫“无连接的套接字”。计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。

因为数据报套接字所做的校验工作少,所以在传输效率方面比流格式套接字要高。

  • 有以下特征:

    强调快速传输而非传输顺序;

    传输的数据可能丢失也可能损毁;

    限制每次传输的数据大小;

    数据的发送和接收是同步的

众所周知,速度是快递行业的生命。用摩托车发往同一地点的两件包裹无需保证顺序,只要以最快的速度交给客户就行。这种方式存在损坏或丢失的风险,而且包裹大小有一定限制。因此,想要传递大量包裹,就得分配发送。

另外,用两辆摩托车分别发送两件包裹,那么接收者也需要分两次接收,所以“数据的发送和接收是同步的”;换句话说,接收次数应该和发送次数相同。

总之,数据报套接字是一种不可靠的、不按顺序传递的、以追求速度为目的的套接字。

数据报套接字也使用 IP 协议作路由,但是它不使用 TCP 协议,而是使用 UDP 协议(User Datagram Protocol,用户数据报协议)。

QQ 视频聊天和语音聊天就使用 SOCK_DGRAM 来传输数据,因为首先要保证通信的效率,尽量减小延迟,而数据的正确性是次要的,即使丢失很小的一部分数据,视频和音频也可以正常解析,最多出现噪点或杂音,不会对通信质量有实质的影响。

注意:SOCK_DGRAM 没有想象中的糟糕,不会频繁的丢失数据,数据错误只是小概率事件。


转载:https://blog.csdn.net/qq_55125921/article/details/129105053
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场