Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】

系列文章目录

C++技能系列
Linux通信架构系列
C++高性能优化编程系列
深入理解软件架构设计系列
高级C++并发线程编程

期待你的关注哦!!!
在这里插入图片描述

现在的一切都是为将来的梦想编织翅膀,让梦想在现实中展翅高飞。
Now everything is for the future of dream weaving wings, let the dream fly in reality.

Linux 网络通信C/S、TCP/IP、Socket

  • 系列文章目录
  • 一、客户端与服务端(C/S)
    • 1.1 客户端 / 服务器角色规律总结
  • 二、网络模型
    • 2.1 OSI 7 层网络模型
    • 2.2 TCP/IP 4层模型
    • 2.3 TCP/IP 的解释和比喻
  • 三、最简单的客户端和服务端的Socket实例
    • 3.2 一个简单的服务器端通信程序范例
    • 3.3 一个简单的客户端通信程序范例
  • 四、TCP和UDP的区别
    • 4.1 TCP / UDP 概念定义
    • 4.2 TCP / UDP 优缺点
    • 4.3 TCP / UDP 用途
  • 五、TCP链接的三次握手
    • 5.1 最大传输单元MTU
    • 5.2 TCP包头的结构
    • 5.3 TCP 数据包收发之间的准备工作
    • 5.4 TCP 三次握手建立的过程
  • 六、TCP断开链接的四次挥手
  • 七、TCP状态转换
    • 7.1、TCP状态转换
    • 7.2、TIME_WAIT状态
    • 7.3、SO_REUSEADDR选项解决什么问题呢?
  • 八、listen队列剖析
    • 8.1 监听套接字队列
    • 8.2 accept函数
  • 九、阻塞与非阻塞I/O、同步与异步I/O
    • 9.1 同步阻塞I/O - [ 卡着,等你给我数据 ]
    • 9.2 同步非阻塞I/O - [ 轮询判断你有没有数据,没数据我就干别的事,有数据我就卡着去取数据 ]
    • 9.3 异步I/O - [ 我注册一个回调函数,你有数据就给我通知过来,有没有我都得去干别的事 ]
    • 9.4 同步复用I/O - [ 等待多个套接字上的任意数据到来 ]
  • 十、TCP粘包、缺包 - 解决方案
    • 10.1 收发数据包格式问题提出
    • 10.2 TCP 黏包、缺包
    • 10.3 TCP 黏包、缺包解决

一、客户端与服务端(C/S)

提到网络编程,首先想到的,就是客户端(C)、服务端(S)这两个概念。客户端是一个程序,服务端也是一个程序。所以C/S就表示 “客户端/服务器”

通过浏览器访问淘宝网示意图:

Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第1张图片

图1_1 通过浏览器访问淘宝网示意图

1.1 客户端 / 服务器角色规律总结

(1)数据通信总在两端(双方)之间进行,其中一端称为客户端,另一端称为服务器端

(2)数据通信的一方总有一方先发起第一个数据包,发起第一个数据包的一方称为客户端;被动收到第一个数据包的一方就称为服务器端

(3)客户端主动发起连接,发出数据请求,建立和服务器的数据通信;服务器被动接收客户端发起的连接请求,并和客户端建立连接。然后,数据就可以从客户端发送到服务器,也可以从服务器发送到客户端,可以双向流动了,这叫双工(彼此都可以发送数据给对方)。

  • 既然服务器被动接收连接,那么客户端必须能找到服务器在哪里。所以客户端要知道服务器端的地址姓名,在网络术语中,服务器地址被称为IP地址,服务器的姓名被称为端口号端口(端口号是一个无符号数字,取值范围为0 ~ 65535)。端口号的作用就是区别不同的服务端的应用程序
  • 既然是双向流动的,那么客户端肯定也要有一个IP地址端口号,这样才能双向流动。其实客户端的IP地址和端口号则不需要专门指定的,客户端程序本身就知道自己所在的计算机的IP地址,操作系统也会自动为客户端分配一个临时的端口号。所以,在编写网络通信程序时,只需要指定访问服务器的IP地址和端口号就可以了。

(4)服务器客户端的关系,是一对多的关系。一个服务器,可以同时服务成千上万个客户端(利用epoll技术)。

Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第2张图片

图1_2 客户端与服务器的IP地址、端口号信息展示

二、网络模型

2.1 OSI 7 层网络模型

OSI 7层模型称为"物链网传会表应",分别代表物理层、数据链路层、网络层、传输层、会话层、表示层、应用层
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第3张图片

图2_1 OSI 7层模型以及各层的解释

其中,物理层,可以理解成网卡、网线这些看的见摸得着的东西。应用层,可以理解成程序员所写的应用程序。

也可以这样理解:7层,就是把一个要发出去的数据包从里到外包裹了7层(就像一个人穿了7件衣服一样,一件套一件),最终把这个包了7层的数据包发送到网络上。

2.2 TCP/IP 4层模型

随着网络发展,网络通信协议不断进化,其中有一个叫做TCP/IP协议脱颖而出,被选为互联网上通信的标准协议。

TCP/IP(Transfer Control Protocol / Internet Protocol)翻译:“传输控制 / 网际协议”。

原来的OSI的7层网络模型就被TCP / IP 简化成了4层,这4层和原来的7层有一个对应关系。如图:
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第4张图片

图2_2 OSI 7层模型与TCP/IP 4层模型对应关系图

TCP/IP这4层怎样理解呢?也很简单,把一个发送出去的数据包从里到外包裹了4层(就像一个人穿了4件衣服,一件套一件),最终把这个包了4层的数据包发送到网络上。

TCP/IP 的4层模型可以细化一下,如图:
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第5张图片

图2_3 TCP/IP 4层模型中对应的TCP/IP名

如图显示了TCP/IP 4层模型对应的协议,最上方就是本项目运行后的进程(用户进程),下面有TCP和UDP,往下有IP等协议,再往下有以太网帧,最后是网卡网线,表示数据包通过网卡和网线(无线网也算网线)发送出去。

例如,粗线标记路线,也就是从用户进程,遵循TCP,遵循IP,用以太网网帧(包装),最终通过物理介质(网卡和网线)把数据包发送到网络上。

2.3 TCP/IP 的解释和比喻

这里以日常生活中的人们上街为例,把人看成要发送出去的数据包,把外面的街道看成网络。所以,人出门上街,就等于把数据包发送到互联网上。

人不能光着身子上街,那样有伤风化,违反人类社会的规则(就像互联网世界也有协议)。

那么,人要上街怎么办,要穿点什么,怎么穿呢?不能直接套外衣和外裤就上街,要先传内衣内裤(相当于TCP),再套上衬衣衬裤(相当于IP),然后套外衣外裤(相当于以太网帧)。

如果要发送一个数据包到网络上,如下:
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第6张图片

图2_4 将要发送的数据包层层包裹,增加三头一尾之后,发送到网络上

所以,要发送一个数据包,系统内部是给这个数据进行了层层包装后才发出去的,并不是直接发出去一个裸数据包。这就是TCP/IP的规定。

三、最简单的客户端和服务端的Socket实例

我们开始能够简单的熟悉TCP/IP通信的客户端程序和服务器程序都要调用哪些主要的API函数,要理解和记忆API函数的调用。(此演示的程序只能支持即使上百个链接,但是后续讲解epoll技术可是能支持单台服务器数万甚至数十万链接的)。
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第7张图片

图3_1 基本的客户端程序、服务端程序工作步骤(调用API顺序)

## 3.1 套接字socket概念 socket(套接字)套接字是什么呢?

套接字就是一个数字,调用socket函数时,就能够生成(返回)这样一个数字。该数字具有唯一性,操作系统保证,该数字一旦被某个程序调用socket函数返回,就一直给这个程序用,直到该程序调用close函数关闭对数字的调用,该数字才被系统回收(回收后如果该程序或其他程序有调用了socket函数,该数字可以给该程序或者其他程序复用)。只要该数字没有被系统回收,不管哪个程序调用socket函数,都不可能返回一个和该数字一样的数字,这就是唯一性。

有个"一切皆文件"的说法,可以把socket看成一个文件描述符。创建之后就可以用这个socket来收发数据:调用send函数并把socket当做参数,就把数据发送到对端;调用recv并把socket当做参数,就能够从对端接收数据。

3.2 一个简单的服务器端通信程序范例

对这个简单的服务器端通信程序说明如下:

(1)核心代码位于main(主函数)中。
(2)调用socket函数来创建一个socket(数字 / 文件描述符)。
(3)定义监听的端口SERV_PORT,被定为9000端口)。
(4)调用bind函数将端口IP地址socket绑到一起,让这三者产生关联关系。
(5)调用listen函数开始监听这个socket(等同于监听SERV_PORT端口)。此时,任何往SERV_PORT端口发送的数据,这个服务器程序都将收到。
(6)用一个for无限循环,调用accept函数等待客户端连入。程序执行的流程会卡在accept函数调用这里等待客户端连入。
(7)当有客户端连入时,accept函数会返回,程序执行流程会往下走。⚠️注意这里的accept函数返回来一个新的socket,这个新的socket就能用来与连入的客户端通信(原来的socket继续在那里监听其他即将连入的客户端)。
(8)通过write函数向accept函数返回的socket上写内容,就等价于给真正的客户端发送数据。
(9)服务器端调用close主动关闭与该客户端的链接。然后开始新一轮的for无限循环(服务器端又卡到accept这里等待客户端的连入)。

简单的服务器端(server)通信程序范例如下:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

//本服务器要监听的端口号,一般1024以下的端口很多都是属于周知端口,所以我们一般采用1024之后的数字做端口号
#define SERV_PORT 9000  

int main(int argc, char *const *argv)
{    
    //这些演示代码的写法都是固定套路,一般都这么写

    //服务器的socket套接字【文件描述符】
    //创建服务器的socket,大家可以暂时不用管这里的参数是什么,知道这个函数大概做什么就行
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);    
	
	//服务器的地址结构体
    struct sockaddr_in serv_addr;                  
    memset(&serv_addr,0,sizeof(serv_addr));
    
    //设置本服务器要监听的地址和端口,这样客户端才能连接到该地址和端口并发送数据
    //选择协议族为IPV4
    serv_addr.sin_family = AF_INET;  
    //绑定我们自定义的端口号,客户端程序和我们服务器程序通讯时,就要往这个端口连接和传送数据              
    serv_addr.sin_port = htons(SERV_PORT);
    //监听本地所有的IP地址;INADDR_ANY表示的是一个服务器上所有的网卡(服务器可能不止一个网卡)多个本地ip地址都进行绑定端口号,进行侦听。         
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); 

	//绑定服务器地址结构体
    bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    //参数2表示服务器可以积压的未处理完的连入请求总个数,客户端来一个未连入的请求,请求数+1,连入请求完成,c/s之间进入正常通讯后,请求数-1
    listen(listenfd, 32);     

    int connfd;
    //指向常量字符串区的指针
    const char *pcontent = "I sent sth to client!"; 
    for(;;)
    {
        //卡在这里,等客户单连接,客户端连入后,该函数走下去【注意这里返回的是一个新的socket——connfd,后续本服务器就用connfd和客户端之间收发数据,而原有的lisenfd依旧用于继续监听其他连接】        
        connfd = accept(listenfd, (struct sockaddr*)NULL, NULL);

		printf("收到客户端连接,发送数据: I sent sth to client! \n");
        //发送数据包给客户端
        //注意第一个参数是accept返回的connfd套接字
        write(connfd,pcontent,strlen(pcontent)); 
        
        //只给客户端发送一个信息,然后直接关闭套接字连接;
        close(connfd); 
    } //end for
    //实际本简单范例走不到这里,这句暂时看起来没啥用
    close(listenfd);     
    return 0;
}

3.3 一个简单的客户端通信程序范例

对这个简单的客户端通信程序说明如下:

(1)核心代码位于main主函数中。
(2)调用socket函数来创建一个socket(数字 / 文件描述符)。
(3)调用inet_pton设定要连接的服务器IP地址和端口号。
(4)调用connect来真正连接服务器端程序,此时,卡在accept函数的服务器端程序的执行流程就会从accept函数返回(服务器端向客户端发送一个字符串)。
(5)客户端调用read(卡在read函数这里等待)来接收服务器端发送过来的内容。客户端第一次调用read,收到了服务器端发送过来的字符串,但循环一次(客户端这里是while循环)再次调用read时,因为服务端调用了close函数,导致客户端的read函数返回一个小于等于0的值(表示服务器端关闭了对应的socket连接),这会使客户端跳出while循环,程序执行流程跳到while后面。
(6)调用close关闭socket,输出信息"程序执行完毕,退出!\n".
(7)整个客户端程序执行完毕。

⚠️客户端和服务端建立连接时,双方都要有IP地址和端口号,但连接一旦建立,双方的通信(双工的收发)只用对应的socket(套接字)即可。

简单的客户端(client)通信程序范例如下:


#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

//要连接到的服务器端口,服务器必须在这个端口上listen着
#define SERV_PORT 9000   

int main(int argc, char *const *argv)
{    
    //这些演示代码的写法都是固定套路,一般都这么写
    
    //创建客户端的socket,大家可以暂时不用管这里的参数是什么,知道这个函数大概做什么就行
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in serv_addr; 
    memset(&serv_addr,0,sizeof(serv_addr));

    //设置要连接到的服务器的信息
	
	//选择协议族为IPV4
    serv_addr.sin_family = AF_INET;    
    //连接到的服务器端口,服务器监听这个地址            
    serv_addr.sin_port = htons(SERV_PORT);        
    //这里为了方便演示,要连接的服务器地址固定写
    
    //IP地址转换函数,把第二个参数对应的ip地址转换第三个参数里边去,固定写法
    if(inet_pton(AF_INET, "192.168.1.126", &serv_addr.sin_addr) <= 0) 
    {
        printf("调用inet_pton()失败,退出!\n");
        exit(1);
    }

    //连接到服务器
    if(connect(sockfd,(struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0)
    {
        printf("调用connect()失败,退出!\n");
        exit(1);
    }

    int n;
    char recvline[1000 + 1]; 
    //仅供演示,非商用,所以不检查收到的宽度,实际商业代码,不可以这么写
    while(( n = read(sockfd,recvline,1000)) > 0) 
    {
    	//实际商业代码要判断是否收取完毕等等,所以这个代码只有学习价值,并无商业价值
        recvline[n] = 0; 
        printf("收到的内容为:%s\n",recvline);
    }
    //关闭套接字
    close(sockfd); 
    printf("程序执行完毕,退出!\n");
    return 0;
}

运行结果如下:
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第8张图片

图3_3 启动服务端、客户端运行结果

四、TCP和UDP的区别

传输层有两种协议,分别是TCP和UDP。

4.1 TCP / UDP 概念定义

(1)TCP(Transmission Control Protocol)又称传输控制协议。

TCP一种可靠的、面向连接的协议。采用TCP发送数据包如果数据在网络上传输丢失,发送方操作系统底层能感知到并重新发送丢失的数据包,这些事情不用程序员操心。如果再丢失,操作系统底层再重新发送(包重传机制)。如此反复尝试几次,实在发送不成功,操作系统会通知程序员(应用程序):对不起数据包实在发送不出去。

(2)UDP(User Datagram Protocol),又称用户数据报协议。

UDP是一种不可靠的、无链接的协议。发送出去一个数据包,对方收没收到,谁都不得而知,除非约定对方收到数据包时要发送回应包。发送方操作系统也不可能替程序员重新发送该数据包。

4.2 TCP / UDP 优缺点

TCP和UDP各有优缺点。

(1)TCP是可靠的协议,需要耗费更多的系统资源以确保数据传输的可靠性。优点是只要链接不断线(如果断线会收到对方的通知),传输给对方的数据一定是正确的(哪怕发送了100G的数据,也1字节都不会有差错)发送方和接收方操作系统共同协作,保证数据包无差错,不丢失,不重复,按顺序到达目的地。

(2)UDP是不可靠协议,发送数据的速度可能更快,因为他不需要确保数据的可靠性,不需要接收方回应,所以数据发送效率高,但是接收方接收到的数据顺序可能与发送方发送的数据顺序不一样。另外UDP不保证数据传输的可靠性。网络繁忙时,利用UDP发送数据包丢失的概率更高。

4.3 TCP / UDP 用途

(1)TCP适用于文件传输、收发邮件等需要准确率高、但效率可以相对较差的场合。与UDP相比,TCP应用范围更广。

(2)UDP适用于聊天软件(如QQ,当然QQ里面也用了部分的TCP),这种网络聊天信息量巨大,服务器压力相当沉重,所以需要采用效率更高、对资源消耗更低的UDP。当然QQ内部也采用了很多算法和优化机制来弥补UDP先天的不足,如丢包问题。

随着硬件的发展,UDP的稳定性不断增强,丢包率不断下降,预计未来使用UDP的场合会越来越多。

五、TCP链接的三次握手

只有TCP有三次握手,UDP没有。

5.1 最大传输单元MTU

MTU(Maximum Transfer Unit,最大传输单元),可以理解为最大传输单元就是每个数据包所能包含的最大字节数,该值约为1.5KB。因为一个数据包中还包含TCP头、IP头等内容,所以,每个数据包中,真正能够容纳的有效数据内容可能无法达到1.5KB,应该在1.46KB左右。

要是发送100KB数据会怎样呢?

当把这100KB数据发送出去时,操作系统会把100KB数据拆成若干个数据包(也叫分片),每个数据包大小约1.5KB(大概会被拆成68个数据包),然后发出去,对端的操作系统收到后再重组这些数据包。拆包、重组的细节不用理会,这68个数据包发送到网络后,中间可能要经过各种路由器、交换机等网络设备,68个数据包走的路径也可能不一样,并且因为一些硬件的差异,这些数据包可能被路由器、交换机再次拆分。总之,TCP最终保证了收发数据的顺序性以及可靠性。

5.2 TCP包头的结构

每个要发送到网络上去的数据包,操作系统都会为其套 三头一尾1个TCP头、1个IP头、1个以太网帧头和1个以太网帧尾)。

TCP头,实际就是一个结构,C++中用struct定义结构,结构中有个成员,TCP头结构如下:
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第9张图片

图5_1 TCP头的结构

(1)TCP头中有很多内容,值得注意的是源端口和目标端口都在里面。
(2)注意一些标志位(URG、ACK、PSH、RST、SYN和FIN),每个标志位都像个开关,处于开启或关闭状态。重点注意SYN和ACK这2个标志位,三次握手的时候这2标志位是开启的。
(3)TCP头部结构之后,紧邻的就是我们要发送的数据。但是,如果一个数据包中没有发送数据包(如该数据包只需发送TCP头部的一些标志位信息),就属于没有包体、只有包头的数据包,一般用于控制信息的传输,不用于数据传输。总之,并不是所有TCP数据包里都会包含具体的要传输的数据,也可能只发送控制信息。

5.3 TCP 数据包收发之间的准备工作

在Linux操作系统上,一切皆文件,所以TCP数据包的收发,也可以看成是文件的收发。TCP数据包的收发是双工的,也就是说数据通信的两端,每一端都可以接收数据,也都可以发送数据。

可以总结出,TCP数据包收发的三大步骤:

(1)建立TCP连接(connect)
(2)多次反复数据收发(read / write)
(3)关闭TCP连接(close)

建立TCP连接,就是TCP的三次握手。因为TCP是一种可靠的、面向连接的协议。而UDP包不存在握手,直接发出去,因为UDP是不可靠的、无连接的协议。

5.4 TCP 三次握手建立的过程

当客户端程序调用connect函数连接服务器端程序时,TCP的三次握手就发生了。

如何理解三次握手呢?
把客户端理解成一个一个人,服务器端也理解成一个人,想象这两个人打电话(建立连接收发信息)。
(1)张三:“你好,我是张三”
(2)李四:“你好,张三,我是李四”
(3)张三:“你好,李四”
问候完毕,张三和李四这2个人就可以聊正题了。
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第10张图片

图5_2 TCP三次握手建立连接示意图

观察图,可以注意到,三次握手就是发送3个数据包而已,而且这3个数据包都是没有包体的数据包。
(1)第1次握手。客户端主动向服务器发送一个SYN标志位置位的TCP数据包。SYN被置位,就表示发起一个TCP连接。
(2)第2次握手。服务器端收到SYN标志位置位的包后,向客户端返回一个SYN和ACK标志位都置位的TCP数据包。
(3)第3次握手。客户端收到服务端发送回来的数据包之后,再次发送ACK标志位置位的数据包,服务端收到该数据包后,客户端和服务器端就正式建立TCP连接,后续双方就可以进行数据的收发了。

为什么TCP是3次握手而不是2次?

TCP之所以要三次握手,是为了确保数据稳定可靠的收发。最重要的原因,是为了尽量减少伪造数据包对服务器的攻击。

如果伪造大量的SYN的标志位置位的数据包不断的给服务器发送链接请求,服务器端的资源就会大量消耗甚至枯竭,正常的连接请求可能就连不上服务器,这就是人们常说的,拒绝服务攻击了。

所以我们要知道,第2次握手时服务端会发送给客户端一个序列号,只有客户端真实存在,并将服务端的序列号通过第3次握手正确的返回服务器时,服务器才认为TCP连接真正建立起来了,如果客户端是伪造的IP地址,客户端肯定收不到服务器在第2次握手时发送的序列号信息,从而无法正确返回给服务端第3次握手包所需的序列号。所以,这种3次握手的方式,能够确保客户端真实存在杜绝伪造的客户端IP地址成功连接服务器的可能性。

六、TCP断开链接的四次挥手

TCP连接断开时的四次挥手,意味着连接的双方向对方说再见(谁主动断开连接,谁发第一个挥手包)。
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第11张图片

图5_3 TCP四次挥手断开连接示意图

(1)第1次挥手。 主动断开连接的一方(这里是服务器端),发送FIN和ACK标志位都置位的TCP包给对端(FIN标志位被置位,就表示本方要断开TCP连接)。
(2)第2次挥手。被动断开连接的一方(这里是客户端)发送ACK标志位置位的TCP包回应对端。
(3)第3次挥手。被动断开连接的一方发送FIN和ACK标志位都置位的TCP包给对端。
(4)第4次挥手。主动断开连接的一方发送ACK标志位置位的TCP包回应对端。

七、TCP状态转换

7.1、TCP状态转换

目前可以得到结论是:相同的IP地址(INADDR_ANY)和相同的端口(SERV_PORT),只能被绑定(bind)1次,第2次绑定(bind)会失败。

我们使用netstat命令观察一下.

Local Address(本地地址)列显示的内容是"0.0.0.0:9000",其中的"0.0.0.0"代表本机可用的任意地址(因为代码中用的是INADDR_ANY),State(转态)列显示的是LISTEN,表示监听中。

服务器上可以有多块网卡,每块网卡可以配置不同的IP地址(甚至一块网卡可以配置多个IP地址),所以,服务器程序中绑定端口时,可以把端口与某个指定的IP地址绑定。当然,如果程序中使用INADDR_ANY,就表示把端口和所有该计算机的IP地址(不管是几块网卡,几个IP地址)都绑定,这就意味着不管从哪个网卡(IP地址)发送过来的数据包,只要发往该端口的,该服务器程序都能收到。
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第12张图片

图7_1 TCP状态转换图

TCP为一个连接定义了11种状态。TCP/IP会根据程序的各种行为(如accept、write、read等)自动在这11种状态之间切换。图7_1最上方为起点,既无论客户端程序还是服务端程序,最初都处于CLOSED状态。

(1) 对于服务端程序,调用了listen函数,就进入了LISTEN状态。对于客户端程序调用connect函数开始和服务器进行3次握手。

(2) 观察客户端的3次握手:进入SYN_SENT状态,3次握手结束之后,又进入ESTABLISHED状态,表示这个TCP连接成功建立,可以进行数据收发了。

(3) 再观察服务端的3次握手:服务端端口开始LISTEN后,等待客户端3次握手的第一次握手的到来,第一次握手后,服务器端会发送SYN、ACK应答,同时TCP连接会进入SYN_RCVD状态,并且该连接等待客户端发送来的第3次握手。如果此时收到了一个ACK,该连接也进入ESTABLISHED状态,表示该TCP连接成功建立,可以进行数据收发了。

值得一提的是,客户端连入后,服务器端为客户端单独产生一个能和服务器通信的socket连接,该链接进入ESTABLISHED状态,而服务器端处于LISTEN(监听)状态的socket依旧处于监听状态(每接到一个连接,服务器就生成一个新的socket连接用于与客户端通信,而最早产生的监听socket一直处于监听状态)。

(4) 现在,服务端程序根据代码write了一些内容给客户端,然后关闭了(TCP断开连接)。TCP在断开连接时有个4次挥手,也就是说,谁主动断开,谁会先发一个标志位FIN置位的数据包给对方。这里是服务器主动断开,所以服务器发送一个FIN标志位置位的数据包给客户端。

(5) 观察四次挥手:

  • 处于ESTABLISHED状态时,要求主动关闭的一方(服务器端)发送FIN包,同时进入FIN_WAIT_1转态并等待对方的回应ACK包,同时进入FIN_WAIT_1状态并等待对方回应ACK包。
  • 被动关闭(客户端)收到FIN包后发送ACK包,同时进入CLOSE_WAIT状态同时发送一个FIN包给服务器端。
    被动关闭方理论上应该不断调用read处理数据,当收到FIN包后,read会返回0,此时被动关闭方也应该调用close,立即由刚刚进入的CLOSE_WAIT状态转换成LAST_ACK状态。如果不调用read或不调用close,很可能导致被动关闭方一直处于CLOSE_WAIT状态,无法达到LAST_ACK状态(netstat观察时出现大量CLOSE_WAIT状态连接的程序员是不是忘记调用read或者close了?)。
    处于LAST_ACK状态后,等待对方回应ACK包。
  • 主动关闭的一方(服务器端)收到对方的ACK包之后,进入FIN_WAIT_2状态并继续等待对方的FIN包。收到FIN包之后进入TIME_WAIT状态,同时发送一个ACK包给客户端。
  • 被关闭的一方(客户端)收到对方的ACK包后,从LAST_ACK状态切换回CLOSED状态。

7.2、TIME_WAIT状态

在这里插入代码片

主动关闭的一方(服务器端)已进入TIME_WAIT状态了。该状态产生了一些后果,所以似乎是比较讨厌的状态。

不难发现,即便服务器进程已经退出,短时间(1~4min)内用netstat命令依然能看到2个连接在9000端口上处于TIME_WAIT状态。如图:

看这种情况就像服务器没退利索一样,残留了一些内容(留了2个处于TIME_WAIT状态的TCP连接)。残留这种状态的TCP,服务器端程序重新启动,绑定9000端口会失败,无法成功启动。

Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第13张图片

图7_1 TCP的四次挥手状态转换图(由服务器主动发送关闭连接请求)

如图:最上方表示客户端和服务端的连接已建立,正在进行正常的数据收发。然后,服务器发送的FIN包关闭连接,客户端回应ACK包,并再次发送FIN包。然后服务器返回一个ACK,双方的连接关闭。从图中可以看到,服务器端进入了TIME_WAIT状态,这个状态大概会持续2MSL(最长的数据包生命周期,约1~4min数据包在网络上存活有时间限制,超时将被路由器丢弃)。

TIME_WAIT状态存在的理由,总结为2点:
①可靠的实现TCP全双工的终止;

⚠️什么叫可靠的实现TCP全双工的终止?怎么体现"可靠"二字?
如果服务器最后发送的ACK(应答)包因为某种原因丢失了,客户端没有收到,那么客户端一定会向服务器端重新发送第3次挥手的FIN包,处于TIME_WAIT状态的服务器就会向客户端重新发送ACK包。

如果没有TIME_WAIT,那么无论客户端有没有收到ACK,服务器都已经发送RST(连接复位)包并关闭连接了,此时客户端重新发送FIN,服务器端将不会返回ACK,从而使客户端报错(正常是4次挥手结束连接,这种报错并结束连接很不友好)。所以,TIME_WAIT有助于可靠地实现TCP全双工连接的终止。

发送数据给对端时,操作系统将待发送数据复制到操作系统的缓冲区。对于每一个TCP连接,操作系统都要开辟出一个收发缓冲区用于处理数据的收和发,当关闭一个TCP连接时,如果发送缓冲区内有数据,操作系统会很优雅地把发送缓冲区的数据发送完毕,最后再发FIN包表示连接关闭。

总结:FIN是一个优雅的关闭的标志,表示正常关闭一个TCP连接。反观RST标志。这不是个善良的标志,出现这个标志的包,一般都是非正常关闭连接,发生了某些异常情况,一般都会导致数据包的丢失(发送缓冲区中的数据无法全部发送)。

②允许老的重复的TCP数据包在网络中消逝(丢弃)。

⚠️什么叫允许老的重复的TCP数据包在网络中消逝(丢弃)?

TCP连接关闭后,主动关闭的一方保持TIME_WAIT状态一定的时间,等候针对该TCP连接的残留的、重复的包发过来(迷途刚找到路的,或由于某个途径的路由器太慢而姗姗来迟的)。

当主动关闭的一方处于TIME_WAIT状态下,只要是符合上述TIME_WAIT记录的对端发送过来的数据包,全都是旧的、重复的包,都会丢弃。

所以,TIME_WAIT状态有实实在在的作用(占坑、占位)。当某个连接处于TIME_WAIT状态,如果它扮演的是服务端的角色,它对应的端口调用bind函数就会失败,需要等1~4min,等该连接的状态从TIME_WAIT变成CLOSED状态,调用bind才会成功。

7.3、SO_REUSEADDR选项解决什么问题呢?

SO_REUSEADDR选项,实际是在setsocketopt函数中使用的,而在setsocketopt函数一般用于在socketbind函数之间调用。setsocketopt函数就是为了解决TIME_WAIT状态时bind调用会提示失败的情况的。

在这里插入代码片

SO_REUSEADDR选项,主要用于解决TIME_WAIT状态导致bind失败的问题。但它并不能解决用同一端口同一地址再次绑定失败的问题。

八、listen队列剖析

listen函数是用来监听端口的,是用在TCP网络通信的服务器角色程序中(TCP连接,服务器角色)。UDP和客户端都不用该函数。

listen函数的调用格式:
int listen(int sockfd, int backlog);

在该调用格式中,第1个参数是监听套接字(用socket函数返回的),我们需要深入理解下backlog参数(最大已完成的连接数)。

8.1 监听套接字队列

Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第14张图片

图8_1 TCP为监听套接字维护的两个队列

对于一个调用listen进行监听的套接字,操作系统会为其维护2个队列:未完成连接队列和已完成连接队列。

(1)未完成连接队列中的连接
(2)已完成连接队列中的连接

TCP连接建立时三次握手、状态转换、函数调用返回、队列状态综合图如图:
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第15张图片

图8_2 TCP连接建立时三次握手、状态转换、函数调用返回、队列状态综合图

(1)客户端的connect调用是什么时候返回的?其实是收到三次握手的第2次握手包(既收到服务器返回的SYN、ACK包)之后就返回了。
(2)RTT代表未完成连接队列中的任意一项在未完成队列中留存的时间,时间长短取决于客户端和服务器。三次握手到建立连接大概需要187ms。
(3)如果一个恶意用户,迟迟不发送三次握手的第3个包,TCP连接就建立不起来,服务器端处于SYN_RCVD的这一项半连接就会停留在未完成连接队列中,停留时间大概是75s,超过这个时间,这一项半链接就会被操作系统删除。

8.2 accept函数

accept函数用于从已完成连接队列中的队首(对头)位置取出一项,返回给进程(服务器程序)。

如果已完成连接队列是空的,accept函数调用就会卡在这里等待(休眠),直到已完成连接队列中有一项内容时才会被唤醒。所以,在正常编写程序时,需要尽快调用accept把已完成连接队列中的项取走。

accept函数返回的是一个套接字(socket),该套接字代表已经用三次握手建立起来的TCP连接(因为accept是从已完成连接队列中取到的连接项)。

服务器程序必须严格区分2个套接字

(1)监听端口的套接字叫监听套接字,只要服务器程序在,该套接字就应该一直存在,目的是随时准备接受(监听到)客户端的连接。
(2)当有客户端连接,操作系统会为每个成功完成三次握手的客户再创建一个套接字(当然是一个已连接的套接字),这个套接字其实就是accept返回的套接字,也就是从已完成连接队列取得的一项。随后服务器用accept返回的套接字和客户端进行通信。

如果已完成连接队列和未完成连接队列之和达到了listen所指定的第2个参数,既队列满了,此时客户端再发送来一个SYN连接请求,服务器端会怎样反应呢?

服务器端会忽略SYN,不给回应。客户端发现SYN没有回应,过一会就会重发这个SYN包。重发几次如果没有都没回应,就认为连接失败(connect失败)。

三次握手完成,连接已放到已完成连接的队列中,等着accept函数从已完成队列中取出。,试想,当accept还没来得及取走这个连接的时候,因为三次握手已经建立了,客户端如果此时发送数据过来,该数据就会被保存在已经连接的套接字的接收缓冲区里,该接收缓冲区的大小就是能接受的最大数据量。

九、阻塞与非阻塞I/O、同步与异步I/O

Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第16张图片

图9_1 四种模型调用接收数据时的代码执行表现

9.1 同步阻塞I/O - [ 卡着,等你给我数据 ]

阻塞,就是用一个函数,该函数就卡在这里,整个程序流程不往下走了(此时进程进入休眠状态)。该函数卡在这里等待一个事件发生,只有这个事件发生了,该函数才会继续往下走(进程才会继续运行)。
这种函数就是阻塞函数,如服务器端使用的accept函数。调用accept时,程序执行流程就卡在accept这里,等待客户端连接,只有客户端连接,三次握手成功,accept才会返回。

这种阻塞并不好,效率很低,为什么呢?

操作系统是通过给每个进程分配一段执行的时间的手段来轮流执行时间叫做"时间片"。每个进程想充分运行,就应该尽量把操作系统为其分配的时间片用完。而现在程序执行流程卡在这里,阻塞了,操作系统就会立即从当前进程切换到另一个进程去执行了,把属于自己的时间片拱手送给别人了。

9.2 同步非阻塞I/O - [ 轮询判断你有没有数据,没数据我就干别的事,有数据我就卡着去取数据 ]

还以accept为例,如果通过调用某个函数,把监听套接字listenfd设置成非阻塞,那么调用accept的时候,就算是没有客户端连接,这个acccept调用也不会卡住,会立即返回(当然返回时有一个错误码,我们通过错误码就能判断accept返回的原因)。这样就能够充分利用操作系统给进程分配的时间片来做别的事(而不是卡在这里的把本属于自己的时间片拱手送人),执行效率更高。

非阻塞模式有2个鲜明的特点:
(1)要不断调用该函数检查有没有数据到来,如果没有,函数会返回一个特殊的错误标记来告诉我们。
(2)如果数据到来,就要把数据从内核缓冲区复制到用户缓冲区,所以,即便是非阻塞模式,复制数据阶段也是卡着完成的。

9.3 异步I/O - [ 我注册一个回调函数,你有数据就给我通知过来,有没有我都得去干别的事 ]

只有异步I/O没有阻塞行为

调用一个异步I/O函数接收数据时,不管有没有数据,该函数都会立即返回。但是,程序员在调用异步I/O函数时要指定一个接收数据的缓冲区(buffer),还要指定一个回调函数,其他的事情操作系统去做了,程序可以自由的干其他的事情。

操作系统会判断数据是否到来,如果到来了,操作系统会把数据复制到我们指定的接收数据的缓冲区(buffer),然后调用我们所指定的回调函数来通知程序。

9.4 同步复用I/O - [ 等待多个套接字上的任意数据到来 ]

系统函数selectpoll用的就是同步I/O,epoll也可以换分到同步I/O范畴。
Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第17张图片

图9_2 同步I/O复用模型

这里涉及到两个函数,首先调用select函数,判断是否有数据(该数据只能判断是否有数据,并不能去取数据),如果没有数据就卡在那里等;如果有数据,select返回,之后调用recvfrom函数去取数据。取数据涉及从内核空间复制到用户空间,所以复制数据时还是要卡着。

所以,同步I/O更麻烦,需要调用2个函数才能取到数据,其优点就是得到所谓I/O复用的能力。

所谓的I/O复用就是多个socket(多个TCP连接)可以绑在一起,我们可以使用同步I/O函数(如select)等待接收数据。换句话说,select的能力是等多条TCP连接上的任意一条有数据到来,然后我们再使用具体函数去收。这就是同步I/O要调用2个函数来收数据的原因。

这种调用1个函数就能判断一批TCP连接是否有数据到的能力,就叫I/O复用(I/O复用 multiplexing,全称"I/O多路复用")。

十、TCP粘包、缺包 - 解决方案

10.1 收发数据包格式问题提出

以网络游戏为例,游戏中两人对打,我给你一拳,这是一条命令,要从客户端发送到服务器;我给你一个加血技能,这也是一条命令,也要从客户端发送到服务器。服务器收到这些命令后,要正确区分出这是2条命令,第一条命令是出拳的,第2条命令是加血的。这2条命令之间肯定是要以某种手段进行分离,这样服务器才能够正确的识别。如labc2表示1打了2一拳,而1def2|30表示1给2加了30点血,这2个命令从客户端发送到服务器,服务器收到后很可能是这样的数据:

1abc21def2|30

既很可能服务器read一下,直接收到的就是1abc21def2|30,这2个数据包粘连到一起了,而不是人们想象的read一下,就读到了1abc2,处理读到的内容;再read一下,又读到了1def2|30,然后再处理读到的内容。服务器怎样识别出这是2条命令,第1条是1abc2,第2条是1def2|30呢?

10.2 TCP 黏包、缺包

TCP数据是存在黏包问题的,客户端向服务器发出多个数据包,服务器接收时的情况很多变。如客户端发送出abc、def、hij 三个数据。

(1)客户端黏包

因为客户端采用了Nagle优化算法,即便客户端3次调用write来发送数据包,Nagle优化算法也很可能把这3次write调用打成1个数据包直接发送个服务器(这属于客户端的数据包黏包)。当然这个可以调用某个函数关闭Nagle优化算法,关闭后,可能这3次write调用就被分3个数据包发送给服务器端,客户端的数据包黏包问题就解决了。代价就是本来1个包可以发送全部内容,现在分成了3个包,每个包都要带TCP头、IP头,以太网帧头,多了这些头,效率显然要差一些。

(2)服务端黏包、缺包

就算客户端不黏包,服务端也存在黏包问题。服务端不可能随时都在recv(接收)数据,可能recv完一次之后要做一些其他操作,需要时间,假设这期间客户端发送3个包到服务端,保存在服务端针对该socket连接(TCP连接)的接收缓冲区中,也就是说abcdefhij都在服务器的接收缓冲区中了。等服务器完成其他操作,调用recv接收数据,还是一次把abcdefhij全部收到,这叫做服务器端黏包。

除了黏包问题,还有其他情况。如客户端分3个包发送abc、def、hij,这几个包比较小,每个包只有3个字节,不会拆包;但是如果发送一个较大的数据包,可能会被系统拆成几个包来发送(如果一次发送的数据超过1400字节,操作系统就会进行拆包)。假设第1个包发送的内容超过了1400字节,被拆包了,发送之后如果网络出现延迟或阻塞,服务器端在接收时很有可能出现以下情况:
第1次服务器收包,收到了客户端所发送的第1个包的前面部分内容;
第2次服务器收包,收到了客户端所发送的第2个包的中间部分内容;
第3次服务器收包,收到了客户端所发送的第3个包的剩余部分内容以及第2个数据包的部分内容。

也就是说,服务器调用recv收包时,收到多少数据都有可能。可能数据包小,一次就收完几个数据包,也有可能因为网络延迟或数据包过大,几次才能收完一个完整的包这种情况就是缺包。

10.3 TCP 黏包、缺包解决

黏包,要解决的就是把几个包逐个拆出来,只要服务器能够正确区分出每个包,黏包、缺包问题就解决了。

所以,要有一个严谨的解决方案,即便客户端伪造发送各种恶意数据包,服务器都不会被畸形数据包搞崩溃(死掉)。

要想正确解决黏包问题,需要程序员给收发的数据包定义一个统一的格式,服务器端及客户端都按照该格式来收发数据。

数据包格式是怎么样的呢?就是包头 + 包体的格式。收发的任何一个数据包,都要遵循这种包头 + 包体的格式。其中包头是固定长度的。

Linux 网络通信C/S、TCP/IP、Socket 最全详解( 9 ) -【Linux通信架构系列 】_第18张图片

图10_3 包头 + 包体的格式定义

包头其实是一个结构,在包头中有一个成员,用于记录整个包的长度(包头 + 包体的长度)。因为包头长度固定,且能够从包头中获取整个包的长度,用整个包的长度减去包头的长度,就可以得到包体的长度。

总结一下:
(1)先收到一定长度的包头;
(2)根据包头的内容,计算出包体的长度;
(3)再收包体长度这么多的数据。
这就收到一个完整的数据包了。黏包问题就解决了。

你可能感兴趣的:(Linux,通信架构实战,linux,c语言,tcp/ip,c++,架构,服务器,网络协议)