目录
一、socket
二、字节序
(一)字节序转换函数
三、Socket地址
(一)通用socket地址
(二)专用socket地址
四、IP地址转换
五、TCP通信流程
(一)TCP和UDP的区别
(二)TCP通信
服务器端(被动接受连接)
客户端(主动)
(三)套接字函数
函数socket:
函数bind
函数listen
函数accept
函数connect
(四)用TCP实现终端聊天
客户端程序
服务端程序
(五)TCP三次握手
握手流程
为什么要三次握手?能不能两次、四次?
为什么每次建立TCP时,初始化的序列号都要求不一样?
初始化序列号ISN如何产生?
(六)TCP滑动窗口
(七)四次挥手
为什么需要四次挥手?
第一次挥手丢失后会发生什么?
第二次挥手丢失了会发生什么?
第三次挥手丢失了会发生什么?
第四次挥手丢失后会发生什么?
(八)TCP通信并发
多进程实现并发服务器
多线程并发服务器
(九)TCP状态转换
为什么TIME_WAIT等待的时间是2MSL?
(十)半关闭、端口复用
半关闭
端口复用
在这推荐一个看Linux内核代码的网站:Linux source code (v6.1) - Bootlin
一、socket
所谓 socket(套接字),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上连应用进程,下连网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议根进行交互的接口。
socket 可以看成是两个网络应用程序进行通信时,各自通信连接中的端点,这是一个逻辑上的概念。它是网络环境中进程间通信的 API,也是可以被命名和寻址的通信端点,使用中的每一个套接字都有其类型和一个与之相连进程。通信时其中一个网络应用程序将要传输的一段信息写入它所在主机的 socket 中,该 socket 通过与网络接口卡(NIC)相连的传输介质将这段信息送到另外一台主机的 socket 中,使对方能够接收到这段信息。socket 是由IP 地址和端口结合的,提供向应用层进程传送数据包的机制。
socket 本身有“插座”的意思,在 Linux 环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。既然是文件,那么理所当然的,我们可以使用文件描述符引用套接字。与管道类似的,Linux 系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。
那么我们可以这么抽象出来这个通讯的过程:
套接字的通信可以分为两部分:
-
服务器端:被动接受连接,一般不会主动发起连接。
-
客户端:主动向服务器发起连接
socket是一套通信的接口,Linux 和 Windows 都有,但是有一些细微的差别。
二、字节序
字节序,顾名思义就是字节的顺序,就是一个大于一个字节类型的数据在内存中的存放顺序(一个字节的数据当然就无需谈顺序的问题)。
现代CPU的累加器一次都能装载(至少)4字节(32位机下),即一个整数。那么这4字节在内存中的排列顺序将影响它被累加器装载成的整数的值,这就是字节序的问题。在各种计算机体系结构中,对于字节、字等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问题,即通信双方交流的信息单元(比特、字节、字、双字等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编码/译码从而导致通信失败。
字节序分为大端字节序(Big-Endian)和小端字节序(Little-Endian)。
-
大端字节序是指一个整数的最高位字节(23 ~ 31 bit)存储在内存的低地址处,低位字节(0~7bit)存储在内存的高地址处。
-
小端字节则相反,指整数的高位字节存储在内存的高地址处,而低位字节则存储在内存的低地址处。
那么我们怎么验证自己的机器是大端还是小端呢,可以使用下面的程序来验证:
#include <stdio.h>
int main()
{
union{
short value; //两个字节
char bytes[sizeof(short)]; //char[2]
}test;
test.value = 0x0102;
// 判断是否为大端
if((test.bytes[0]==1)&&(test.bytes[1]==2))
{
printf("big big big\n");
}else if((test.bytes[0]==2)&&(test.bytes[1]==1)){
printf("small small small\n");
}else
{
printf("nor nor nor\n");
}
return 0;
}
为了解决上述问题,我们就需要一个字节序转换函数。
(一)字节序转换函数
当格式化的数据在两台使用不同字节序的主机之间直接传递时,接收端必然错误的解释之。解决问题的方法是:发送端总是把要发送的数据转换成大端字节序数据后再发送,而接收端知道对方传送过来的数据总是采用大端字节序,所以接收端可以根据自身采用的字节序决定是否对接收到的数据进行转换(小端机转换,大端机不转换)。
网络字节顺序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释,网络字节顺序采用大端排序方式。
BSD Socket提供了封装好的转换接口,方便程序员使用。包括从主机字节序到网络字节序的转换函数:htons
、htonl
;从网络字节序到主机字节序的转换函数:ntohs
、ntohl
。
#include <arpa/inet.h>
// 转换端口
uint16_t htons(uint16_t hostshort); // 主机字节序 - 网络字节序
uint16_t ntohs(uint16_t netshort); // 主机字节序 - 网络字节序
// 转IP
uint32_t htonl(uint32_t hostlong); // 主机字节序 - 网络字节序
uint32_t ntohl(uint32_t netlong); // 主机字节序 - 网络字节序
其中,h代表的是主机(host),主机字节序;to代表转换为什么;n代表网络字节序;s代表unsigned short;l代表的是unsigned int;
//转换端口
unsigned short a = 0x0102;
printf("a = %x\n",a);
unsigned short b = htons(a);
printf("b = %x\n",b);
//转换IP
char buf[4] = {192,128,1,100};
int num = *(int *)buf; //四个字节的char可以看做一个整形数
int result = htonl(num);
int sum = htonl(num);
unsigned char *p = (char *)∑
printf("%d %d %d %d\n",*p,*(p+1),*(p+2),*(p+3));
//ntohl
unsigned char buf1[4] = {1,1,168,192}; //假设这是个小端
int num1 = *(int *)buf1;
int sum1 = ntohl(num1);
unsigned char *p1 = (unsigned char *)&sum1;
printf("%d %d %d %d\n",*p1,*(p1+1),*(p1+2),*(p1+3)); //变成大端 192.168.1.1
三、Socket地址
当客户端发送消息到服务器时,需要找到我们自己对应的那个服务器,这个时候就需要IP以及端口号。
socket地址其实就是一个结构体,封装端口号和IP等信息,后面的socket相关的api中需要用到这个socket地址。
(一)通用socket地址
socket 网络编程接口中表示 socket 地址的是结构体sockaddr
,其定义如下:
#include <bits/socket.h>
struct sockaddr {
sa_family_t sa_family;
char sa_data[14]; //IP+Port
};
typedef unsigned short int sa_family_t;
-
sa_family
成员是地址族类型(sa_family_t
)的变量。地址族类型通常与协议族类型对应。常见的协议族(protocol family
,也称domain
)和对应的地址族入下所示:
-
宏
PF_*
和AF_*
都定义在bits/socket.h
头文件中,且后者与前者有完全相同的值,所以二者通常混用。 -
sa_data
成员用于存放 socket 地址值。但是,不同的协议族的地址值具有不同的含义和长度,如下所示:
由上表可知,14 字节的sa_data
根本无法容纳多数协议族的地址值。因此,Linux 定义了下面这个新的通用的 socket 地址结构体,这个结构体不仅提供了足够大的空间用于存放地址值,而且是内存对齐的:
#include <bits/socket.h>
struct sockaddr_storage
{
sa_family_t sa_family;
unsigned long int __ss_align;
char __ss_padding[ 128 - sizeof(__ss_align) ];
};
typedef unsigned short int sa_family_t;
上面那个结构体只兼容了IPV4。
(二)专用socket地址
很多网络编程函数诞生早于IPV4协议,那个时候都使用的是struct sockaddr
结构体,为了向前兼容,现在sockaddr
退化成了void*
的作用,传递一个地址给函数,至于这个函数sockaddr_in
还是sockaddr_in6
,由地址族确定,然后函数内部再强制转化为所需的地址类型。
- UNIX 本地域协议族使用如下专用的 socket 地址结构体:
#include <sys/un.h>
struct sockaddr_un
{
sa_family_t sin_family;
char sun_path[108];
};
- TCP/IP协议族有
sockaddr_in
和sockaddr_in16
两个专用的socket地址结构体,他们分别用于IPV4和IPV6:
#include <netinet/in.h>
struct sockaddr_in
{
sa_family_t sin_family; /* __SOCKADDR_COMMON(sin_) */
in_port_t sin_port; /* Port number.*/
struct in_addr sin_addr; /* Internet address.*/
/* Pad to size of `struct sockaddr'. */ //这里是空的
unsigned char sin_zero[sizeof (struct sockaddr) - __SOCKADDR_COMMON_SIZE - sizeof (in_port_t) - sizeof (struct in_addr)];
};
struct in_addr
{
in_addr_t s_addr;
};
struct sockaddr_in6
{
sa_family_t sin6_family;
in_port_t sin6_port;
/* Transport layer port # */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr;
/* IPv6 address */
uint32_t sin6_scope_id; /* IPv6 scope-id */
};
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))
所有专用 socket 地址(以及 sockaddr_storage)类型的变量在实际使用时都需要转化为通用 socket 地址类型 sockaddr(强制转化即可),因为所有 socket 编程接口使用的地址参数类型都是 sockaddr。
四、IP地址转换
-
可以将字符串的IP地址转换为整数并且调整其字节序
-
也可以做主机字节序以及网络字节序之间的转换
通常,人们习惯用可读性好的字符串来表示 IP 地址,比如用点分十进制字符串表示 IPv4 地址,以及用十六进制字符串表示 IPv6 地址。但编程中我们需要先把它们转化为整数(二进制数)方能使用。而记录日志时则相反,我们要把整数表示的 IP 地址转化为可读的字符串。下面 3 个函数可用于用点分十进制字符串表示的 IPv4 地址和用网络字节序整数表示的 IPv4 地址之间的转换:
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp); // IP转换为int类型整数
int inet_aton(const char *cp, struct in_addr *inp); // 第二个参数用于保存转换后的结果。返回值代表是否返回成功
char *inet_ntoa(struct in_addr in); // 转换为字符串
上面那些函数都是不可以重用的函数,下面有几个新的,可以完成上面三个函数的功能,并且同时适用于IPV4和IPV6:
#include <arpa/inet.h>
// p:点分十进制的IP字符串,n:表示network,网络字节序的整数
int inet_pton(int af, const char *src, void *dst);
af:地址族: AF_INET AF_INET6
src:需要转换的点分十进制的IP字符串
dst:转换后的结果保存在这个里面
// 将网络字节序的整数,转换成点分十进制的IP地址字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
af:地址族: AF_INET AF_INET6
src: 要转换的ip的整数的地址
dst: 转换成IP地址字符串保存的地方
size:第三个参数的大小(数组的大小)
返回值:返回转换后的数据的地址(字符串),和 dst 是一样的
那么使用可以看看下面的示例:
#include <stdio.h>
#include <arpa/inet.h>
int main() {
// 创建一个ip字符串,点分十进制的IP地址字符串
char buf[] = "192.168.1.4";
unsigned int num = 0;
// 将点分十进制的IP字符串转换成网络字节序的整数
inet_pton(AF_INET, buf, &num);
unsigned char * p = (unsigned char *)#
printf("%d %d %d %d\n", *p, *(p+1), *(p+2), *(p+3));
// 将网络字节序的IP整数转换成点分十进制的IP字符串
char ip[16] = "";
const char * str = inet_ntop(AF_INET, &num, ip, 16);
printf("str : %s\n", str);
printf("ip : %s\n", str);
printf("%d\n", ip == str);
return 0;
}
五、TCP通信流程
(一)TCP和UDP的区别
TCP和UDP均为传输层的协议。
-
UDP:用户数据报协议,面向无连接,可以单播,广播,面向数据报,不可靠,不会关心数据是否已经传达,也不会备份数据,网络环境不好可能会丢包,优点在于其效率高。
-
TCP:传输控制协议,面向连接,可靠的(数据安全),基于字节流,仅支持单播传输。
UDP | TCP | |
---|---|---|
是否创建连接 | 无连接 | 面向连接 |
是否可靠 | 不可靠 | 可靠的 |
连接的对象个数 | 一对一、一对多、多对一、多对多 | 一对一 |
传输的方式 | 面向数据报 | 面向字节流 |
首部开销 | 8个字节 | 最少20个字节 |
适用场景 | 实时应用(视频会议、直播)、DNS、SNMP、广播通信 | 可靠性高的应用(文件传输),FTP,HTTP/HTTPS |
具体TCP、UDP的头部的信息在上一篇博客中已经提及,这里就不过多的描述,接下来再细一点的总结这些知识:
1. 连接
-
TCP 是面向连接的传输层协议,传输数据前先要建立连接。
-
UDP 是不需要连接,即刻传输数据。
2. 服务对象
-
TCP 是一对一的两点服务,即一条连接只有两个端点。
-
UDP 支持一对一、一对多、多对多的交互通信
3. 可靠性
-
TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达。
-
UDP 是尽最大努力交付,不保证可靠交付数据。但是我们可以基于 UDP 传输协议实现一个可靠的传输协议,比如 QUIC 协议,具体可以参见这篇文章:如何基于 UDP 协议实现可靠传输?
4. 拥塞控制、流量控制
-
TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。
-
UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
5. 首部开销
-
TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是
20
个字节,如果使用了「选项」字段则会变长的。 -
UDP 首部只有 8 个字节,并且是固定不变的,开销较小。
6. 传输方式
-
TCP 是流式传输,没有边界,但保证顺序和可靠。
-
UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序。
7. 分片不同
-
TCP 的数据大小如果大于 MSS 大小,则会在传输层进行分片,目标主机收到后,也同样在传输层组装 TCP 数据包,如果中途丢失了一个分片,只需要传输丢失的这个分片。
-
UDP 的数据大小如果大于 MTU 大小,则会在 IP 层进行分片,目标主机收到后,在 IP 层组装完数据,接着再传给传输层。
-
MTU:一个网络包的最大长度,以太网中一般为
1500
字节。 -
MSS:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度;
(二)TCP通信
如何唯一确定一个TCP连接?需要确定的源地址、源端口、目的地址、目的端口。
服务器端(被动接受连接)
-
创建一个用于监听的套接字,监听是否有客户端的连接,这个套接字其实就是一个文件描述符。
-
将这个监听文件描述符和本地的IP和端口绑定(IP和端口就是服务器的地址信息)。客户端链接服务器的时候就是使用这个IP和端口。
-
上两部为创建,设置监听,监听的fd开始工作,监听是否有数据传入读缓冲区。
-
阻塞等待,当有客户端发起连接时,解除阻塞,接受客户端的链接,会得到一个和客户端通信的套接字(新的fd)。
-
通信:接收数据、发送数据。
-
通信结束,断开连接。
服务端最大并发TCP连接数不能达到理论上限,也就是客户端IP数*客户端端口数,会受到下面的因素影响:
-
文件描述符限制,每个 TCP 连接都是一个文件,如果文件描述符被占满了,会发生 too many open files。Linux 对可打开的文件描述符的数量分别作了三个方面的限制:
-
系统级:当前系统可打开的最大数量,通过
cat /proc/sys/fs/file-max
查看; -
用户级:指定用户可打开的最大数量,通过
cat /etc/security/limits.conf
查看; -
进程级:单个进程可打开的最大数量,通过
cat /proc/sys/fs/nr_open
查看;
-
-
内存限制,每个 TCP 连接都要占用一定内存,操作系统的内存是有限的,如果内存资源被占满后,会发生 OOM。
客户端(主动)
-
创建一个用于通信的套接字(fd)。
-
连接服务器,需要指定链接的服务器的IP和端口。
-
连接成功了,客户端可以直接和服务器通信(接收数据、发送数据)。
-
通信结束,断开连接。
(三)套接字函数
需要包含的头文件有如下:
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
函数socket:
int socket(int domain, int type, int protocol);
- 功能:创建一个套接字
- 参数:
- domain: 协议族
AF_INET : ipv4
AF_INET6 : ipv6
AF_UNIX, AF_LOCAL : 本地套接字通信(进程间通信)
- type: 通信过程中使用的协议类型
SOCK_STREAM : 流式协议(TCP...)
SOCK_DGRAM: 报式协议(UDP...)
- protocol : 具体的一个协议。一般写0
- SOCK_STREAM : 流式协议默认使用 TCP
- SOCK_DGRAM: 报式协议默认使用 UDP
- 返回值:
- 成功:返回文件描述符,操作的就是内核缓冲区。
- 失败:-1
函数bind
这个函数是操作于服务端的函数:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命名
- 功能:绑定,将fd 和本地的IP + 端口进行绑定
- 参数:
- sockfd : 通过socket函数得到的文件描述符
- addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息
- addrlen : 第二个参数结构体占的内存大小
- 返回值:
- 成功:0
- 失败:-1
函数listen
这个也是应用于服务端:
int listen(int sockfd, int backlog);
- 功能:监听这个socket上的连接
- 参数:
- sockfd : 通过socket()函数得到的文件描述符
- backlog : 未连接的和已经连接的和的最大值, 5
在监听中,会创建两个队列,一个是未连接队列,一个是已经连接队列,这是为了后面三次握手时可以进行处理。最大值为5的话,代表有连接以及未连接合起来最大应该是10,正常指定5就可以了。
我们可以使用以下命令来查看自己计算机限制的队列长度:
cat /proc/sys/net/core/somaxconn
函数accept
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 功能:接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接
- 参数:
- sockfd : 用于监听的文件描述符
- addr : 传出参数,记录了连接成功后客户端的地址信息(ip,port)
- addrlen : 指定第二个参数的对应的内存大小
- 返回值:
- 成功 :用于通信的文件描述符,不同的客户端有不同的文件描述符
- -1 : 失败
函数connect
这个是在客户端中使用的函数。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 功能: 客户端连接服务器
- 参数:
- sockfd : 用于通信的文件描述符
- addr : 客户端要连接的服务器的地址信息
- addrlen : 第二个参数的内存大小
- 返回值:成功 0, 失败 -1
最后读写数据的时候是引用Linux函数:
ssize_t write(int fd, const void *buf, size_t count); // 写数据
ssize_t read(int fd, void *buf, size_t count); // 读数据
(四)用TCP实现终端聊天
客户端程序
//TCP client
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// 1. 创建套接字
int fd = socket(AF_INET,SOCK_STREAM,0);
if(fd == -1)
{
perror("socket");
exit(-1);
}
// 2. 连接服务器
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET,"127.0.0.1",&serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999);
int ret = connect(fd,(struct sockaddr *)&serveraddr,sizeof(serveraddr));
if(ret == -1)
{
perror("connect");
exit(-1);
}
char recvBuf[1024] = {0}; //发送
char sendBuf[1024] = {0}; //接收
int len = 0;
// 3.通信
while(1)
{
memset(sendBuf,0,1024);
//char *data = "hello,i am client";
printf("enter somethint to server:");
scanf("%s",sendBuf);
//给服务端发送数据
write(fd,sendBuf,strlen(sendBuf));
sleep(1);
//读取服务器端的数据
len = read(fd,recvBuf,sizeof(recvBuf));
if(len == -1)
{
perror("read");
exit(-1);
}else if(len > 0)
{
printf("recv server data: %s\n",recvBuf);
}else if(len == 0)
{
printf("server closed...\n");
break;
}
}
close(fd);
return 0;
}
服务端程序
//TCP server
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// 1.创建socket(用于监听的套接字)
int lfd = socket(AF_INET,SOCK_STREAM,0);
if(lfd == -1)
{
perror("socket");
exit(-1);
}
// 2.绑定
struct sockaddr_in sadrr;
sadrr.sin_family = AF_INET; // 确定协议
//主机字节序转为网络字节序,绑定本机IP,可以使用命令ifconfig来查询,本机的话也可以使用127.0.0.1
// inet_pton(AF_INET,"127.0.0.1",sadrr.sin_addr.s_addr); //确定IP
//如果要做一个服务器的话可以这么写
sadrr.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0代表任意地址
sadrr.sin_port = htons(9999); //确定端口,并且转为网络字节序
int ret = bind(lfd,(struct sockaddr *)&sadrr,sizeof(sadrr));
if(ret == -1)
{
perror("bind");
exit(-1);
}
//3.监听
ret = listen(lfd,8);
if(ret == -1)
{
perror("listen");
exit(-1);
}
//4.接收客户端连接
struct sockaddr_in clientaddr;
socklen_t len1 = sizeof(clientaddr);
int cfd = accept(lfd,(struct sockaddr *)&clientaddr,&len1);
if(cfd == -1)
{
perror("accept");
exit(-1);
}
//5.输出客户端信息
char clientIP[16];
inet_ntop(AF_INET,&clientaddr.sin_addr.s_addr,clientIP,sizeof(clientIP));
unsigned short clientPort = ntohs(clientaddr.sin_port);
printf("client IP is :%s,port is %d\n",clientIP,clientPort);
// 6.通信
// 获取客户端数据
char recvBuf[1024] = {0};
char sendBuf[1024] = {0};
int len = 0;
while(1)
{
memset(sendBuf,0,1024);
len = read(cfd,recvBuf,sizeof(recvBuf));
if(len == -1)
{
perror("read");
exit(-1);
}else if(len>0)
{
printf("recv client data : %s \n",recvBuf);
}else if(len == 0)
{
// 表示客户端断开连接
printf("client close...\n");
break;
}
//给客户端发送一个数据
//char *data = "hello , i am server";
printf("send msg to client:");
scanf("%s",sendBuf);
write(cfd,sendBuf,strlen(sendBuf));
}
//关闭文件描述符
close(cfd);
close(lfd);
return 0;
}
最后的运行效果:
(五)TCP三次握手
握手流程
-
TCP 是一种面向连接的单播协议,在发送数据前,通信双方必须在彼此间建立一条连接。所谓的“连接”,其实是客户端和服务器的内存里保存的一份关于对方的信息,如 IP 地址、端口号等。
-
TCP 可以看成是一种字节流,它会处理 IP 层或以下的层的丢包、重复以及错误问题。在连接的建立过程中,双方需要交换一些连接的参数。这些参数可以放在 TCP 头部。
-
TCP 提供了一种可靠、面向连接、字节流、传输层的服务,采用三次握手建立一个连接。采用四次挥手来关闭一个连接。
三次握手的目的是保证双方互相之间建立了连接。三次握手发生在客户端连接时,即调用了connect函数,底层会通过TCP来进行握手。
在这继续补充一下TCP的头部:
-
16 位端口号(port number):告知主机报文段是来自哪里(源端口)以及传给哪个上层协议或应用程序(目的端口)的。进行 TCP 通信时,客户端通常使用系统自动选择的临时端口号。
-
32 位序号(sequence number):一次 TCP 通信(从 TCP 连接建立到断开)过程中某一个传输方向上的字节流的每个字节的编号。假设主机 A 和主机 B 进行 TCP 通信,A 发送给 B 的第一个TCP 报文段中,序号值被系统初始化为某个随机值 ISN(Initial Sequence Number,初始序号值)。那么在该传输方向上(从 A 到 B),后续的 TCP 报文段中序号值将被系统设置成 ISN 加上该报文段所携带数据的第一个字节在整个字节流中的偏移。例如,某个 TCP 报文段传送的数据是字节流中的第 1025 ~ 2048 字节,那么该报文段的序号值就是 ISN + 1025。另外一个传输方向(从B 到 A)的 TCP 报文段的序号值也具有相同的含义。
-
32 位确认号(acknowledgement number):用作对另一方发送来的 TCP 报文段的响应。其值是收到的 TCP 报文段的序号值 + 标志位长度(SYN,FIN) + 数据长度 。假设主机 A 和主机 B 进行TCP 通信,那么 A 发送出的 TCP 报文段不仅携带自己的序号,而且包含对 B 发送来的 TCP 报文段的确认号。反之,B 发送出的 TCP 报文段也同样携带自己的序号和对 A 发送来的报文段的确认序号。当他加一时,代表其收到了
SYN
或者是FIN
。 -
4 位头部长度(head length):标识该 TCP 头部有多少个 32 bit(4 字节)。因为 4 位最大能表示15,所以 TCP 头部最长是60 字节。
-
6 位标志位包含如下几项:
-
URG 标志,表示紧急指针(urgent pointer)是否有效。
-
ACK 标志,表示确认号是否有效。我们称携带 ACK 标志的 TCP 报文段为确认报文段。
-
PSH 标志,提示接收端应用程序应该立即从 TCP 接收缓冲区中读走数据,为接收后续数据腾出空间(如果应用程序不将接收到的数据读走,它们就会一直停留在 TCP 接收缓冲区中)。
-
RST 标志,表示要求对方重新建立连接。我们称携带 RST 标志的 TCP 报文段为复位报文段。
-
SYN 标志,表示请求建立一个连接。我们称携带 SYN 标志的 TCP 报文段为同步报文段。
-
FIN 标志,表示通知对方本端要关闭连接了。我们称携带 FIN 标志的 TCP 报文段为结束报文段。
-
-
16 位窗口大小(window size):是 TCP 流量控制的一个手段。这里说的窗口,指的是接收通告窗口(Receiver Window,RWND)。它告诉对方本端的 TCP 接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度。
-
16 位紧急指针(urgent pointer):是一个正的偏移量。它和序号字段的值相加表示最后一个紧急数据的下一个字节的序号。因此,确切地说,这个字段是紧急指针相对当前序号的偏移,不妨称之为紧急偏移。TCP 的紧急指针是发送端向接收端发送紧急数据的方法。
TCP的三次握手是通过标志位来进行标志握手顺序的:
也就是当我们客户端,发送消息时,我们将SYN
置一后表示请求服务端连接, 服务端收到消息后,也要建立连接,并且产生应答,这个时候就需要发送标志位SYN
以及ACK
,最后客户端再次确定已经连接,这个时候再次发送SYN
。为什么要进行这么多次连接,是因为需要确定彼此是否可以接收也可以发送。后面的seq以及ack分别是序号以及确认号,首先是要确保数据的完整性以及顺序性等等。
对于后面带着的确认号以及序号,有着服务端以及客户端之分,具体初始值是怎么样的,是需要去研究一下那个分配算法的,那么我们再继续细讲这个东西的规则:
-
客户端以及服务端的初始值不一样,具体看分配。
-
当客户端发送第一次消息过去后,假设
cseq
为1000,那么这个时候服务端的sack
的应答,要在这个cseq
上进行加1,也就是1001,并且也要发送自己的请求连接标志位,这个时候就发送我们服务端的初始值。 -
当客户端接收到服务端的初始值以及连接信号时,我们会在服务端的初始值上+1,并且付上应答信号返回。
-
接下来就进入了通信,还是继续客户端的发送消息,这个时候由于我们没有接收到服务端所传送过来的消息,所以我们保留了上一次的
cack
,并且使用了sack
的序号1001,并且规定了发100个字节的内容,真正的内容是1101。 -
之后服务端为了做好应答工作,返回
sack
为1101。 -
之后客户端继续发,那么我们就需要在1101后面继续往后发,所以需要发送1101(200),实则为1301,
cack
继续保持不变,那么服务端就需要返回1301
,这样子就可以确保数据的完整性以及顺序性了。
那么最后做个总结,TCP的三次握手:
第一次握手:
-
客户端将SYN标志置为1。
-
生成一个随机的32位的序号seq=J,这个序号后面是可以携带数据(数据的大小)。
第二次握手:
-
服务器端接收客户端的连接:ACK置为1。
-
服务器会回发一个确认序号:ack=客户端的序号+数据长度(字节)+SYN/FIN(按一个字节算)
-
服务器端会向客户端发起连接请求:SYN = 1.
-
服务器会生成一个随机序号:seq = K
第三次握手:
-
客户端应答服务器的连接请求:ACK = 1。
-
客户端回复收到了服务器端的数据:ack = 服务端的序号 + 数据长度 + SYN/FIN(按一个字节算)
为什么要三次握手?能不能两次、四次?
-
三次握手才可以阻止重复历史连接的初始化(主要原因)。
比如说,客户端首先发送了一个
SYN(seq=90)
报文,这个时候客户端突然宕机了,而且这个SYN
报文还被网络阻塞了,服务端并没有接收到,接着客户端重启后,又重新向服务端重新建立,发送了SYN(seq=100)
报文,这里并不是重传SYN,重传的SYN的序列号是一样的,这里是重新传个新的:
笔记源于《小林coding》。
在客户端连续发送多次SYN(都是同一个四元组)建立连接的报文,在网络拥堵的情况下:
-
一个「旧 SYN 报文」比「最新的 SYN 」 报文早到达了服务端,那么此时服务端就会回一个
SYN + ACK
报文给客户端,此报文中的确认号是 91(90+1)。 -
客户端收到后,发现自己期望收到的确认号应该是 100+1,而不是 90 + 1,于是就会回 RST 报文。
-
服务端收到
RST
报文后,就会释放连接。 -
后续最新的 SYN 抵达了服务端后,客户端与服务端就可以正常的完成三次握手了。
这里的旧SYN报文,其实就是历史连接,TCP使用三次握手建立连接的主要目的就是防止历史连接初始化了连接。如果只有两次握手,就无法阻止历史连接,服务端并没有中间状态给客户端来阻止历史连接,导致服务端可能建立于一个历史连接,造成资源浪费。如下图:
如果这么采用两次握手来建立连接,就代表服务端在向客户端发送数据前,并没有阻止掉历史连接,导致服务端建立了一个历史连接,又白白发送了数据,妥妥的浪费了服务端的资源。
-
三次握手才可以同步双方的初始序列号。
TCP协议的通信双方,都必须维护一个序列号,序列号是可靠传输的一个关键因素,它的作用有如下:
接收方可以去除重复的数据;接收方可以根据数据包的序列号按序接收;可以标识发送除去的数据包中,哪些是已经被对方收到的(可以通过ACK报文中的序列号知道)。
所以序列号其实在TCP中是一个很重要的存在,所以当客户端发送携带初始序列号的SYN报文的时候,需要服务端回一个ACK应答报文,确保客户端的SYN报文已被服务端成功接收,那当服务端发送初始序列号给客户端的时候,依然也需要客户端的应答回答,这样一来一回,才能确保双方的初始序列号能被可靠的同步。
-
由于没有第三次握手,服务端不清楚客户端是否收到了自己发送的建立连接的
ACK
确认报文,所以每收到一个SYN
就只能先主动建立一个连接
所以最后总结:
-
「两次握手」:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号;
-
「四次握手」:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。
为什么每次建立TCP时,初始化的序列号都要求不一样?
-
为了防止历史报文被下一个相同四元组的连接接收(主要方面);
-
为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收;
假设我们每次建立客户端连接的时候,客户端和服务端的初始化序号都是从0开始:
-
客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。
-
紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接;
-
在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。
可以看到,如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题。如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文。
初始化序列号ISN如何产生?
起始 ISN
是基于时钟的,每 4 微秒 + 1,转一圈要 4.55 个小时。
RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。
-
M
是一个计时器,这个计时器每隔 4 微秒加 1。 -
F
是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。
可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。
(六)TCP滑动窗口
滑动窗口(Sliding window)是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题。滑动窗口协议是用来改善吞吐量的一种技术,即容许发送方在接收任何应答之前传送附加的包。接收方告诉发送方在某一时刻能送多少包(称窗口尺寸)。 TCP 中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为 0时,发送方一般不能再发送数据报。 滑动窗口是 TCP 中实现诸如 ACK 确认、流量控制、拥塞控制的承载结构。
窗口可以理解为缓冲区的大小,滑动窗口的大小会随着发送数据和接收数据而变化。通信双方都有发送缓冲区和接收数据的缓冲区。所以对于客户端以及服务器,各自都有发送缓冲区的窗口还有接收缓冲区的窗口。
可以看看上面的图:
发送方的缓冲区:
-
白色格式:空闲的空间。
-
灰色格子:数据已经被发送出去了,但是还没有被接收。
-
紫色格子:还没有发送出去的数据。
接收方的缓冲区:
-
白色格子:空闲的空间。
-
紫色格子:已经接收到的数据。
那么可以看看下面这张图,也就是滑动窗口配合TCP的使用:
-
mss
:Maximum Segment Size(一条数据的最大数据量)。 -
win
:滑动窗口,16位的窗口大小。
在上图中,客户端发送的第一条消息代表,当前发送数据,客户端滑动窗口大小还有4096个长度,但是最大的数据量只能有1460,之后服务端应答,表示自己滑动窗口大小为6144,最大的数据可发1024;之后三次握手后,客户端连续发送了6K的数据,刚好满足了接收端滑动窗口的最大大小6144,之后服务端就开始处理,首先是处理了2K,发送给客户端说自己还有2K的空间,之后继续处理,继续发送给客户端说自己有4K的空间,并且每次都告诉客户端,你发给我的6145个字节我都接收到了;客户端继续发送字节,并且发送了终止信号;之后等待服务端自己处理,等待服务端处理完后,客户端才会应答,然后结束。(后面挥手的时候会细讲)
总结流程:
-
客户端向服务器发起连接,客户端的滑动窗口是4096,一次发送的最大数据量是1460。
-
服务器接收连接请求,告诉客户端服务器窗口大小为6144,一次发送的最大数据量是1024.
-
第三次握手
-
4-9客户端连续给服务器发送了6K的数据,每次发送1K。
-
第十次,服务器告诉客户端:发送的6K数据已经接收到,也已经存储在缓冲区中,并且缓冲区数据已经处理了2K,窗口大小是2K。
-
第十一次,服务器告诉客户端:发送的6K数据已经接收到,也已经存储在缓冲区中,并且缓冲区数据已经处理了4K,窗口大小是4K。
-
第十二次,客户端给服务器发送了1K的数据。
-
第十三次,客户端主动请求和服务器断开连接,并且给服务器发送了1k的数据。(第一次挥手)
-
第十四次,服务器回复ACK 8194,同意断开连接的请求,并且告诉客户端我已经收到了刚才发过来的数据,并且告诉滑动窗口的大小。
-
第十五、十六次,通知客户端滑动窗口的大小。(第二次挥手)
-
第十七次,第三次挥手,服务器给客户端发送
FIN
,请求断开连接。 -
第十八次,第四次挥手,客户端同意了服务器端的断开请求。
注意:
-
第一次握手不可以携带数据,第三次握手时客户端是可以带数据的,但是接收方不可以,只能等待三次握手后。
(七)四次挥手
-
客户端打算关闭连接,此时会发送一个 TCP 首部
FIN
标志位被置为1
的报文,也即FIN
报文,之后客户端进入FIN_WAIT_1
状态。 -
服务端收到该报文后,就向客户端发送
ACK
应答报文,接着服务端进入CLOSE_WAIT
状态。 -
客户端收到服务端的
ACK
应答报文后,之后进入FIN_WAIT_2
状态。 -
等待服务端处理完数据后,也向客户端发送
FIN
报文,之后服务端进入LAST_ACK
状态。 -
客户端收到服务端的
FIN
报文后,回一个ACK
应答报文,之后进入TIME_WAIT
状态 -
服务端收到了
ACK
应答报文后,就进入了CLOSE
状态,至此服务端已经完成连接的关闭。 -
客户端在经过
2MSL
一段时间后,自动进入CLOSE
状态,至此客户端也完成连接的关闭。
主动关闭连接的,才有 TIME_WAIT 状态。
为什么需要四次挥手?
-
关闭连接时,客户端向服务端发送
FIN
时,仅仅表示客户端不再发送数据了但是还能接收数据。 -
服务端收到客户端的
FIN
报文时,先回一个ACK
应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送FIN
报文给客户端来表示同意现在关闭连接。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK
和 FIN
一般都会分开发送,因此是需要四次挥手。
但是在特定情况下,四次挥手是可以变成三次挥手的,具体情况可以看这篇:TCP 四次挥手,可以变成三次吗?
第一次挥手丢失后会发生什么?
当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端的连接进入到 FIN_WAIT_1
状态。
正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则会很快变为 FIN_WAIT2
状态。
如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK 的话,也就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries
参数控制。
当客户端重传 FIN 报文的次数超过 tcp_orphan_retries
后,就不再发送 FIN 报文,则会在等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到第二次挥手,那么直接进入到 close
状态。
举个例子,假设 tcp_orphan_retries 参数值为 3,当第一次挥手一直丢失时,发生的过程如下图:
具体过程:
-
当客户端超时重传 3 次 FIN 报文后,由于 tcp_orphan_retries 为 3,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次挥手(ACK报文),那么客户端就会断开连接。
第二次挥手丢失了会发生什么?
当服务端收到客户端的第一次挥手后,就会先回一个 ACK 确认报文,此时服务端的连接进入到 CLOSE_WAIT
状态。
在前面我们也提了,ACK 报文是不会重传的,所以如果服务端的第二次挥手丢失了,客户端就会触发超时重传机制,重传 FIN 报文,直到收到服务端的第二次挥手,或者达到最大的重传次数。
举个例子,假设 tcp_orphan_retries 参数值为 2,当第二次挥手一直丢失时,发生的过程如下图:
具体过程:
-
当客户端超时重传 2 次 FIN 报文后,由于 tcp_orphan_retries 为 2,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次挥手(ACK 报文),那么客户端就会断开连接。
这里提一下,当客户端收到第二次挥手,也就是收到服务端发送的 ACK 报文后,客户端就会处于 FIN_WAIT2
状态,在这个状态需要等服务端发送第三次挥手,也就是服务端的 FIN 报文。
对于 close 函数关闭的连接,由于无法再发送和接收数据,所以FIN_WAIT2
状态不可以持续太久,而 tcp_fin_timeout
控制了这个状态下连接的持续时长,默认值是 60 秒。
这意味着对于调用 close 关闭的连接,如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭,如下图:
如果使用了shutdown函数会发生什么,可以看看下面的半关闭。
第三次挥手丢失了会发生什么?
当服务端(被动关闭方)收到客户端(主动关闭方)的 FIN 报文后,内核会自动回复 ACK,同时连接处于 CLOSE_WAIT
状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。
此时,内核是没有权利替代进程关闭连接,必须由进程主动调用 close 函数来触发服务端发送 FIN 报文。
服务端处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文,同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。
如果迟迟收不到这个 ACK,服务端就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retrie
s 参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的。
举个例子,假设 tcp_orphan_retrie
s = 3,当第三次挥手一直丢失时,发生的过程如下图:
具体过程:
-
当服务端重传第三次挥手报文的次数达到了 3 次后,由于 tcp_orphan_retries 为 3,达到了重传最大次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第四次挥手(ACK报文),那么服务端就会断开连接。
-
客户端因为是通过 close 函数关闭连接的,处于 FIN_WAIT_2 状态是有时长限制的,如果 tcp_fin_timeout 时间内还是没能收到服务端的第三次挥手(FIN 报文),那么客户端就会断开连接。
第四次挥手丢失后会发生什么?
当客户端收到服务端的第三次挥手的 FIN 报文后,就会回 ACK 报文,也就是第四次挥手,此时客户端连接进入 TIME_WAIT
状态。
在 Linux 系统,TIME_WAIT 状态会持续 2MSL (具体可以看下面的半关闭)后才会进入关闭状态。
然后,服务端(被动关闭方)没有收到 ACK 报文前,还是处于 LAST_ACK 状态。
如果第四次挥手的 ACK 报文没有到达服务端,服务端就会重发 FIN 报文,重发次数仍然由前面介绍过的 tcp_orphan_retries
参数控制。
举个例子,假设 tcp_orphan_retries 为 2,当第四次挥手一直丢失时,发生的过程如下:
具体过程:
-
当服务端重传第三次挥手报文达到 2 时,由于 tcp_orphan_retries 为 2, 达到了最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第四次挥手(ACK 报文),那么服务端就会断开连接。
-
客户端在收到第三次挥手后,就会进入 TIME_WAIT 状态,开启时长为 2MSL 的定时器,如果途中再次收到第三次挥手(FIN 报文)后,就会重置定时器,当等待 2MSL 时长后,客户端就会断开连接。
(八)TCP通信并发
多进程实现并发服务器
对于刚刚上面所实现的一个终端聊天功能,它只能处理一个TCP会话,所以需要多线程以及多进程来处理并发的问题,主要的思路可以这么处理:
-
一个父进程,多个子进程。
-
父进程负责等待并接收客户端的连接。
-
子进程:完成通信,接受一个客户端连接,就创建一个子进程用于通信。
那么服务端的代码就可以这么编写:
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <wait.h>
#include <errno.h>
// 如果单单写这个,这个信号会导致服务端结束accept,会被软中断打断
// 这就说明如果有一个客户端结束,这个服务端也无法再接收客户端
void recyleChild(int arg)
{
while(1)
{
//保证所有信号都可以处理
int ret = waitpid(-1,NULL,WNOHANG);
if(ret == -1)
{
// 所有子进程都回收完成
break;
}else if(ret == 0)
{
// 还有子进程活着
break;
}else if(ret > 0)
{
// 子进程被回收
printf("child is over,the pid is %d\n",ret);
}
}
}
int main()
{
struct sigaction action;
action.sa_flags = 0;
sigemptyset(&action.sa_mask);
action.sa_handler = recyleChild;
//注册信号捕捉
sigaction(SIGCHLD,&action,NULL);
// 创建Socket
int lfd = socket(AF_INET,SOCK_STREAM,0);
if(lfd == -1)
{
perror("socket");
exit(-1);
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(9999);
saddr.sin_addr.s_addr = INADDR_ANY;
// 绑定
int ret = bind(lfd,(struct sockaddr *)&saddr,sizeof(saddr));
if(ret == -1)
{
perror("bind");
exit(0);
}
// 监听
ret = listen(lfd,128);
if(ret == -1)
{
perror("listen");
exit(-1);
}
// 不断循环等待客户端连接
while(1){
struct sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr);
// 接受连接,这里会阻塞
// 为了解决上面那个信号的问题,我们需要在这里添加判断
int cfd = accept(lfd,(struct sockaddr *)&cliaddr,&len);
if(cfd == -1)
{
if(errno == EINTR)
{
continue;
}
perror("accept");
exit(-1);
}
// 每一个连接进来,创建一个子进程跟客户端通信
pid_t pid = fork();
if(pid == 0)
{
//子进程
//获取客户端信息
char cliIP[16];
inet_ntop(AF_INET,&cliaddr.sin_addr.s_addr,cliIP,sizeof(cliIP));
unsigned short cliPort = ntohs(cliaddr.sin_port);
printf("client ip is : %s,port is %d\n",cliIP,cliPort);
// 接收客户端发来的数据
char recvBuf[1024] = {0};
while (1)
{
int len = read(cfd,&recvBuf,sizeof(recvBuf));
if(len == -1)
{
perror("read");
exit(-1);
}
else if(len>0)
{
printf("recv client data is : %s\n",recvBuf);
}
else if(len == 0)
{
printf("client close...\n");
break;
}
write(cfd,recvBuf,strlen(recvBuf));
}
close(cfd);
exit(0);
}
}
close(lfd);
return 0;
}
这个代码有个细节,就是回收子进程的资源为什么要一定要使用信号而不使用waitpid
:
-
父进程中直接调用
wait/waitpid
,如果一直没有新客户端链接,父进程也会一直阻塞在accept
中,无法执行到wait处。 -
如果不通过信号,直接使用
waitpid
,如果没有while(1)
,执行waitpid
的时候没有子进程,子进程无法回收,加上这个waitpid
一次只能回收一个子进程,所以要用while去轮询,如果父进程不通过信号,这个时候父进程就无法执行其他的事情,包括监听、创建子进程等等,这个信号就是一个中断程序,处理完回收子进程后再恢复现场。
之后修改完还是有这么一个问题,当你的子进程结束后,其父进程会收到这个错,按道理来说,它应该打印一个错误,这个错误为client close...
才对。这个原因应该是出于客户端在退出的时候,并没有关闭客户端的文件描述符,这个时候进程结束,服务端还在继续收客户端的消息但是却读取出错的情况,所以这个问题需要从客户端入手,使用下方的代码即可:
最后就可以打开很多个终端,并且都往服务器进行接收数据,可以发现数据通信正常,这个时候我们可以实现一个回射,也就是我们客户端发什么,服务器也发什么回来,这个即为客户端代码:
//TCP client
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// 1. 创建套接字
int fd = socket(AF_INET,SOCK_STREAM,0);
if(fd == -1)
{
perror("socket");
exit(-1);
}
// 2. 连接服务器
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET,"127.0.0.1",&serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999);
int ret = connect(fd,(struct sockaddr *)&serveraddr,sizeof(serveraddr));
if(ret == -1)
{
perror("connect");
exit(-1);
}
char recvBuf[1024] = {0}; //接收
char sendBuf[1024] = {0}; //发送
int i = 0;
int len = 0;
// 3.通信
while(1)
{
sprintf(sendBuf,"data:%d\n",i++);
//给服务端发送数据
write(fd,sendBuf,strlen(sendBuf));
//读取服务器端的数据
len = read(fd,recvBuf,sizeof(recvBuf));
if(len == -1)
{
perror("read");
exit(-1);
}else if(len > 0)
{
printf("recv server data: %s\n",recvBuf);
}else if(len == 0)
{
printf("server closed...\n");
break;
}
sleep(1);
}
close(fd);
return 0;
}
多线程并发服务器
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
struct sockInfo {
int fd; // 通信的文件描述符
struct sockaddr_in addr; //客户端信息
pthread_t tid; // 线程号
};
struct sockInfo sockinfos[128]; // 规定可以支持的客户端个数
void * working(void * arg) {
// 子线程和客户端通信 cfd 客户端的信息 线程号
// 获取客户端的信息
struct sockInfo * pinfo = (struct sockInfo *)arg;
char cliIp[16];
inet_ntop(AF_INET, &pinfo->addr.sin_addr.s_addr, cliIp, sizeof(cliIp));
unsigned short cliPort = ntohs(pinfo->addr.sin_port);
printf("client ip is : %s, prot is %d\n", cliIp, cliPort);
// 接收客户端发来的数据
char recvBuf[1024];
while(1) {
int len = read(pinfo->fd, &recvBuf, sizeof(recvBuf));
if(len == -1) {
perror("read");
exit(-1);
}else if(len > 0) {
printf("recv client : %s\n", recvBuf);
} else if(len == 0) {
printf("client closed....\n");
break;
}
write(pinfo->fd, recvBuf, strlen(recvBuf) + 1);
}
close(pinfo->fd);
return NULL;
}
int main() {
// 创建socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
if(lfd == -1){
perror("socket");
exit(-1);
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(9999);
saddr.sin_addr.s_addr = INADDR_ANY;
// 绑定
int ret = bind(lfd,(struct sockaddr *)&saddr, sizeof(saddr));
if(ret == -1) {
perror("bind");
exit(-1);
}
// 监听
ret = listen(lfd, 128);
if(ret == -1) {
perror("listen");
exit(-1);
}
// 初始化数据
int max = sizeof(sockinfos) / sizeof(sockinfos[0]);
for(int i = 0; i < max; i++) {
bzero(&sockinfos[i], sizeof(sockinfos[i]));
sockinfos[i].fd = -1;
sockinfos[i].tid = -1;
}
// 循环等待客户端连接,一旦一个客户端连接进来,就创建一个子线程进行通信
while(1) {
struct sockaddr_in cliaddr;
int len = sizeof(cliaddr);
// 接受连接
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &len);
struct sockInfo * pinfo;
for(int i = 0; i < max; i++) {
// 从这个数组中找到一个可以用的sockInfo元素
if(sockinfos[i].fd == -1) {
pinfo = &sockinfos[i];
break;
}
// 防止没有了还继续创建线程,让其在这等待处理完
if(i == max - 1) {
sleep(1);
i--;
}
}
pinfo->fd = cfd;
memcpy(&pinfo->addr, &cliaddr, len);
// 创建子线程,传递对应参数
pthread_create(&pinfo->tid, NULL, working, pinfo);
// 使其不阻塞,设置线程分离,结束的时候可以自己释放资源
pthread_detach(pinfo->tid);
}
close(lfd);
return 0;
}
(九)TCP状态转换
图中加粗的英文字体就是各个状态,仔细的可以看看下面这幅图:
其中红色实线是客户端,绿色虚线是服务端,黑色实线是异常。
注意到最后的标志位TIME_WAIT
,这里写着要经过两倍的报文段寿命,有个名词叫2MSL
(Maximum Segment Lifetime),主动断开连接的一方,最后进入一次TIME_WAIT状态,这个状态会持续2MSL。
-
MSL:官方建议是2分钟,实际是30s,主要是保证TCP的安全性以及可靠性,保证对方可以在这个时间段内收到ACK的回应。
当 TCP 连接主动关闭方接收到被动关闭方发送的 FIN 和最终的 ACK 后,连接的主动关闭方必须处于TIME_WAIT 状态并持续 2MSL 时间。
这样就能够让 TCP 连接的主动关闭方在它发送的 ACK 丢失的情况下重新发送最终的 ACK。
主动关闭方重新发送的最终 ACK 并不是因为被动关闭方重传了 ACK(它们并不消耗序列号,被动关闭方也不会重传),而是因为被动关闭方重传了它的 FIN。事实上,被动关闭方总是重传 FIN 直到它收到一个最终的 ACK。
为什么TIME_WAIT等待的时间是2MSL?
MSL
是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL
字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。
IME_WAIT 等待 2 倍的 MSL,比较合理的解释是: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间。
比如,如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 FIN
报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL。
可以看到 2MSL时长 这其实是相当于至少允许报文丢失一次。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。
2MSL
的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时。
在 Linux 系统里 2MSL
默认是 60
秒,那么一个 MSL
也就是 30
秒。Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。
其定义在 Linux 内核代码里的名称为 TCP_TIMEWAIT_LEN:
#define TCP_TIMEWAIT_LEN (60*HZ)
如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里 TCP_TIMEWAIT_LEN 的值,并重新编译 Linux 内核。
(十)半关闭、端口复用
半关闭
当 TCP 链接中 A 向 B 发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入 FIN_WAIT_2状态),并没有立即发送 FIN 给 A,A 方处于半连接状态(半开关),此时 A 可以接收 B 发送的数据,但是 A 已经不能再向 B 发送数据。
可以使用API来控制实现半连接状态:
#include <sys/socket.h>
int shutdown(int sockfd, int how);
sockfd: 需要关闭的socket的描述符
how: 允许为shutdown操作选择以下几种方式:
SHUT_RD(0): 关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。
该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。
SHUT_WR(1):关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。
SHUT_RDWR(2):关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR,相当于是close。
使用 close 中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为 0 时才关闭连接。shutdown 不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。
注意:
-
如果有多个进程共享一个套接字,close 每被调用一次,计数减 1 ,直到计数为 0 时,也就是所用进程都调用了 close,套接字将被释放。
-
在多进程中如果一个进程调用了 shutdown(sfd, SHUT_RDWR) 后,其它的进程将无法进行通信。但如果一个进程 close(sfd) 将不会影响到其它进程。
但是注意,如果主动关闭方使用 shutdown 函数关闭连接,指定了只关闭发送方向,而接收方向并没有关闭,那么意味着主动关闭方还是可以接收数据的。
此时,如果主动关闭方一直没收到第三次挥手,那么主动关闭方的连接将会一直处于 FIN_WAIT2
状态(tcp_fin_timeout
无法控制 shutdown 关闭的连接)。如下图:
端口复用
查看网络信息相关的命令:
netstat
后面可带参数:
-a 所有的socket
-p 显示正在使用socket的程序的名称
-n 直接使用IP地址,而不通过域名服务器
比如上面的程序,我们要看它的一些网络信息,我们可以输入:
netstat -anp | grep 9999
可以发现在通信过程中,状态是不会改变的,但是当你按下结束的时候,可以看到上述的状态变化。当服务器跟客户端发起断开连接后,服务器会进入TIME_WAIT状态:
那么这个时候再次启动服务端,会出现警告bind:Address already in use
。
那么当我们需要在上一个服务端结束后又立刻开启服务端,这就需要端口复用,端口复用最常见的用途在于:
-
防止服务器重启时之前绑定的端口还未释放。
-
程序突然退出而系统没有释放端口。
那么就可以使用下面这个函数:
#include <sys/types.h>
#include <sys/socket.h>
// 设置套接字的属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_toptlen);
参数:
- sockfd : 要操作的文件描述符
- level : 级别 - SOL_SOCKET (端口复用的级别)
- optname : 选项的名称
- SO_REUSEADDR 允许重用本地地址
- SO_REUSEPORT 允许重用本地端口
- optval : 端口复用的值(整形)
- 1 : 可以复用
- 0 : 不可以复用
- optlen : optval参数的大小
端口复用,设置的时机是在服务器绑定端口之前。
setsockopt();
bind();
那么程序的改进就可以这么写:
#include <stdio.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
// 创建socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
if(lfd == -1) {
perror("socket");
return -1;
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
//修改于这个地方
//int optval = 1;
//setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
int optval = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
// 绑定
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if(ret == -1) {
perror("bind");
return -1;
}
// 监听
ret = listen(lfd, 8);
if(ret == -1) {
perror("listen");
return -1;
}
// 接收客户端连接
struct sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
if(cfd == -1) {
perror("accpet");
return -1;
}
// 获取客户端信息
char cliIp[16];
inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, cliIp, sizeof(cliIp));
unsigned short cliPort = ntohs(cliaddr.sin_port);
// 输出客户端的信息
printf("client's ip is %s, and port is %d\n", cliIp, cliPort );
// 接收客户端发来的数据
char recvBuf[1024] = {0};
while(1) {
int len = recv(cfd, recvBuf, sizeof(recvBuf), 0);
if(len == -1) {
perror("recv");
return -1;
} else if(len == 0) {
printf("客户端已经断开连接...\n");
break;
} else if(len > 0) {
printf("read buf = %s\n", recvBuf);
}
// 小写转大写
for(int i = 0; i < len; ++i) {
recvBuf[i] = toupper(recvBuf[i]);
}
printf("after buf = %s\n", recvBuf);
// 大写字符串发给客户端
ret = send(cfd, recvBuf, strlen(recvBuf) + 1, 0);
if(ret == -1) {
perror("send");
return -1;
}
}
close(cfd);
close(lfd);
return 0;
}
这样子就不会出现端口复用的问题了。客户端代码:
#include <stdio.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main() {
// 创建socket
int fd = socket(PF_INET, SOCK_STREAM, 0);
if(fd == -1) {
perror("socket");
return -1;
}
struct sockaddr_in seraddr;
inet_pton(AF_INET, "127.0.0.1", &seraddr.sin_addr.s_addr);
seraddr.sin_family = AF_INET;
seraddr.sin_port = htons(9999);
// 连接服务器
int ret = connect(fd, (struct sockaddr *)&seraddr, sizeof(seraddr));
if(ret == -1){
perror("connect");
return -1;
}
while(1) {
char sendBuf[1024] = {0};
fgets(sendBuf, sizeof(sendBuf), stdin);
write(fd, sendBuf, strlen(sendBuf) + 1);
// 接收
int len = read(fd, sendBuf, sizeof(sendBuf));
if(len == -1) {
perror("read");
return -1;
}else if(len > 0) {
printf("read buf = %s\n", sendBuf);
} else {
printf("服务器已经断开连接...\n");
break;
}
}
close(fd);
return 0;
}