Linux网络编程基础4(TCP通信过程中的主要状态,端口复用,IO复用(I/O多路转接技术)之select、poll)
在上一节中,Linux网络编程基础2(socket编程,字节序,网络套接字函数,服务端创建连接的过程,客户端创建连接的过程,socket函数封装)我们有写到服务器和客户端的多并发模式,其中的读取数据read
和发送数据write
,我们在看别人代码中可能会写到recv
和send
函数,其实这两者唯一的区别在于一个flag
参数:
1.数据接收 | ssize_t read(int fd,void *buf, size_t count); ssize_t recv(int sockfd, void *buf, size_t len, int flags); |
2.数据发送 | ssize_t write(int fd,const void *buf, size_t count); ssize_t send(int sockfd, const void *buf, size_t len, int flags); |
ssize_t类型其实本质就是int类型
int send(int s, const void *msg, size_t len, int flags);
flags取值有:
0
: 与write()无异
MSG_DONTROUTE
:告诉内核,目标主机在本地网络,不用查路由表
MSG_DONTWAIT
:将单个I/O操作设置为非阻塞模式
MSG_OOB
:指明发送的是带外信息
int recv(int s, void *buf, size_t len, int flags);
flags取值有:
0
:常规操作,与read()相同
MSG_DONTWAIT
:将单个I/O操作设置为非阻塞模式
MSG_OOB
:指明发送的是带外信息
MSG_PEEK
:可以查看可读的信息,在接收数据后不会将这些数据丢失
MSG_WAITALL
:通知内核直到读到请求的数据字节数时,才返回。
一般flag
都是用0
。
1. TCP过程中的状态转换
1.1 TCP过程中状态转换流程图
三次握手时
客户端收到服务器发送的SYN+ACK的时候,客户端的状态就变成了ESTABLISHED,当进入该状态的时候,说明该端可以发送数据进行通信了。
四次挥手时
谁先关闭连接谁的状态先发生变化
注意:
图中,进入TIME_WAIT的一方不会立刻放开连接,而会等待2MSL的时间
MSL一般是30秒,2MSL的时间是1min。 主动断开连接的一方要等待2MSL时间,然后结束进程。
等待这段时间是为了对端能正常的销毁进程,为保证对端能收到ack,如果对端没有收到发起断开的一方发送的最后一个ack时,2MSL可以补发ack。为什么会补发ack?因为对端没收到ack就会补发FIN。
1.2 分析:哪些状态能捕捉到,哪些捕捉不到(假定客户端发起断开)
捕捉不到
客户端
- SYN_SENT
- FIN_WAIT_1
服务端
- SYN_RCVD
- CLOSE_WAIT
- LAST_ACK
捕捉得到
客户端
- ESTABLISHED
- FIN_WAIT2
- TIME_WAIT
服务端
- ESTABLISHED
1.3 半关闭和shutdown函数
半关闭的意思是:
- A给B发送了FIN(A调用了close函数), 但是B没有给A发送FIN(B没有调用close)
- A断开了与B的连接, 但是B没有断开与A的连接
半关闭的特点:
- A不能给B发送数据, A可以收B发送的数据
- B可以给A发送数据
我们平时的关闭操作也就是close
,虽然把当前文件描述符关闭了,但是在关闭前如果复制了(dup
和dup2
函数)文件描述符,再关闭是仅仅关闭当前文件描述符,被复制的文件描述符还可以继续用,这时我们需要一个函数:
函数 shutdown
int shutdown(int sockfd, int how);
sockfd: 要半关闭的一方对应的文件描述符,通信的文件描述符
how: SHUT_RD - 0 - 读
SHUT_WR - 1- 写
SHUT_RDWR -2 - 读写
使用shutdown
之后,不管你赋值了多少文件描述符,操作了一个,所有的就都失效了
1.4 netstat命令
可以捕捉到进程的网络相关状态信息。
netstat
-a (all)显示所有选项,默认不显示LISTEN相关
-p 显示建立相关链接的程序名
-n 拒绝显示别名,能显示数字的全部转化成数字。
-t (tcp)仅显示tcp相关选项
-u (udp)仅显示udp相关选项
-l 仅列出有在 Listen (监听) 的服务状态
例子——查看监听8888端口的进程
netstat -apn | grep 8888
2. 端口复用
当服务器进程端口连接关闭(ctrl+C)再重启,会显示以下返回值:
因为进入time wait状态了。
想解决端口的占用,我们就需要给端口复用。
端口复用的用途
- 防止服务器重启时之前绑定的端口还未释放
- 程序突然退出系统没有释放端口
设置方法:
int opt = 1;
setsockopt( sockfd, SOL_SOCKET,
SO_REUSEADDR,
(const void *)&opt,
sizeof(opt));
端口复用只是该函数的一个功能。
man手册截图(这俩函数在UNIX网络编程第七章)
UNIX网络编程电子书下载链接:百度网盘
提取码:l1rx
注意:端口复用函数要设置在绑定之前
应用示例:
int main(int argc, const char* argv[]){
// 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1){
perror("socket error");
exit(1);
}
// lfd 和本地的IP port绑定
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET; // 地址族协议 - ipv4
server.sin_port = htons(8888);
server.sin_addr.s_addr = htonl(INADDR_ANY);
//设置端口复用
int flag = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
int ret = bind(lfd, (struct sockaddr*)&server, sizeof(server));
if(ret == -1){
perror("bind error");
exit(1);
}
...
}
3. IO复用
通常IO操作(比如read
和write
)都是阻塞I/O的,当调用read
时,如果没有数据收到,线程或者进程就会被挂起,直到收到数据。
线程内存和切换开销
由于CPU的核数或超线程数一般都不大,比如4,8,16,32,64,128,比如4个核要跑1000个线程,那么每个线程的时间槽非常短,而线程切换非常频繁。
-
线程是有内存开销的,1个线程可能需要512K(或2M)存放栈,那么1000个线程就要512M(或2G)内存。
-
线程的切换,或者说上下文切换是有CPU开销的,当大量时间花在上下文切换的时候,分配给真正的操作的CPU就要少很多。
3.1 IO操作方式
3.1.1 阻塞等待
举个形象的例子,把任务操作看成快递员送快递,阻塞等待就是在家里等待快递小哥电话,等有快递来了,电话响起,处理任务。
有少量快递(任务)还好,快递之间没有竞争,都能够准时送达快递,但是快递如果多了,比如今天一天你将会有一千个快递,当有快递小哥给你打电话签收快递的同时又有别的若干个快递小哥给你打电话,此时你正在通话中,为阻塞等待,只有当你把当前任务处理完才能处理下一个,所以阻塞等待的优缺点也很明显:
优点:不占用cpu宝贵的时间片
缺点:同一时刻只能处理一个操作,效率低。
3.1.2 非阻塞,忙轮询
也同样是收快递的例子,此时我们不是被动地去等待快递员收快递,而是主动地去了解快递员的信息,什么时候来,我好做打算,然后就每隔一会儿去了解下任务信息,有任务来即处理任务,如果多任务,则给多任务做规划,让其有序排队进行。
优缺点也同样很明显:
优点:提高程序的执行效率
缺点:消耗CPU
3.2 IO操作方式弊端的解决方式
解决方案——使用IO多路转接技术(IO多路复用)select
/poll
/epoll
I/O多路复用:多路网络连接复用一个IO线程。
使用一个线程来检查I/O流(Socket)的就绪状态。通过记录跟踪每个I/O流(Socket)的状态,来同时管理多个I/O流 。
多个Socket复用功能是在内核驱动实现的。
在处理1000个连接时,只需要1个线程监控就绪状态,就绪的连接开一个线程处理就可以了,这样需要的线程数大大减少,减少了内存开销和上下文切换的CPU开销。
还是收快递的例子,以上我们说到IO复用是委托内核(一个线程)去帮我们做一些事情,委托内核告诉我们,连接我们的客户端中,有哪些客户端要与我们进行通信,这个内核(一个线程)就相当于是菜鸟驿站,我们所有的快递统统先给菜鸟驿站处理,然后菜鸟驿站再通知我们取快递。
IO复用技术中,select
/poll
/epoll
之间还是有区别的
这几个图比较形象:
综上,select
/poll
/epoll
可以分类成两部分:
第一种:select
/poll
——遍历线性表来检测谁跟我们通信了
- 只会告诉我有几个客户端与我们通信了,分别是谁,我们不知道。我们需要把所有连接的客户端去进行遍历,找出都有谁跟我通信了。
第二种:epoll
——遍历红黑树来检测谁跟我们通信了
- 不仅告诉我们几个给我们通信了,还会告诉我们跟我们通信的客户端都是谁。
3.3 I/O多路转接技术实现步骤
-
先构造一张有关文件描述符的列表, 将要监听的文件描述符添加到该表中
-
然后调用一个函数,监听该表中的文件描述符,直到这些描述符表中的一个进行I/O操作时,该函数才返回。
–该函数为阻塞函数
–函数对文件描述符的检测操作是由内核完成的 -
在返回时,它告诉进程有多少(哪些)描述符要进行I/O操作。
3.4 select
3.4.1 select相关函数
函数原型:
int select( int nfds,
fd_set *readfds, 传入传出参数(重要)
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
fd_set:描述符集合(long类型数组)
- 参数:
nfds: 要检测的文件描述中最大的fd+1;最大:1024
readfds: 需要检测的可读文件描述符的集合
writefds: 需要检测的可写文件描述符的集合
exceptfds: 需要检测的异常文件描述符的集合
timeout: 超时时间
NULL: 永久阻塞
当检测到fd变化的时候返回
struct timeval a;
a.tv_sec = 10;
a.tv_usec = 0;
- 返回值:
-1 出错
=0 超时
>0 获取到数据
文件描述符操作函数(重要):
No. | 参数 | 含义 |
---|---|---|
1 | FD_ZERO(fd_set *fdset) | 清空文件描述符集 |
2 | FD_SET(int fd,fd_set *fdset) | 设置监听的描述符(把监听的描述符设置为1) |
3 | FD_CLR(int fd,fd_set *fdset) | 清除监听的描述符(把监听的描述符设置为0) |
4 | FD_ISSET(int fd,fd_set *fdset) | 判断描述符是否设置(判断描述符是否设置为1) |
3.4.2 使用select的优缺点
-
优点:
跨平台 -
缺点:
每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
select支持的文件描述符数量太小了,默认是1024
3.4.3 为什么select中默认描述符数量是1024?
下图所示是fd_set
的内核代码片段截图
通过截图可以看到fd_set实际上是_kernel_fd_set结构体,而结构体里面只有一个数组,因此fd_set实际上是一个数组,数组的长度是__FDSET_LONGS
,查看__FDSET_LONGS
的定义可知,他的大小等于__FD_SETSIZE/__NFDBITS
,定义出的FD_SETSIZE
宏的大小是1024
,_NFDBITS
的大小是(8*sizeof(unsigned long))
算一下,1024/(8*(32位则unsigned long的值为4, 64位则unsigned long的值为8,我们假定是64位))=16=__FDSET_LONGS
,因此数组大小是16,类型是long,64位系统中long是8字节,每个字节8位,
因此fd_set的二进制标志位数量就是:16* 8 *8=1024
3.4.4 select工作过程分析
举个例子:
现有客户端A,B,C,D,E, F连接到服务器
分别对应文件描述符 3, 4,100,101,102,103
首先创立两个文件描述符表
fd_set reads, temp;
//temp用于作为select传入的参数,因为select函数会把传入的参数的值修改掉。
初始化
FD_ZERO(&reads);
然后把连进来的客户端逐个注册到reads文件描述符表里
FD_SET(3, &reads);
FD_SET(4, &reads);
...
FD_SET(103, &reads);
委托内核监测
select(103+1, &reads, NULL, NULL, NULL);
调用函数之后,内核就开始干活了。
内核先把参数reads的内容从用户空间复制一份表到内核空间,然后依次遍历复制过来的表,如果该二进制位的值为1,说面该标志位是一个客户端连接的文件描述符,则去查看内核缓冲区的read空间中是否有数据,如果有数据,则该位置的值不变,如果没有数据,则将该位置的值置为0.(内核检测完成之后,修改了fd_set
表)
比如ABC发送了数据,则
修改之后,这份表又会从内核区拷贝到用户区,新拷贝过来的表会覆盖我的 &reads ,因此调用select的时候,传入的 &reads 应该是原始数据的拷贝,也就是我们需要把reads拷贝一份给temp,然后再传,即:
select(103+1, &temp, NULL, NULL, NULL);
我们再去遍历 &temp就能找到哪些客户端发送了数据给我,select函数的返回值只是告诉你有几个客户端发送数据。
3.4.5 程序中使用select函数的伪代码
select的IO转接是异步的。
通过下面伪代码可以看出,当判断完新连接之后,要在下一轮while循环中才看去读取客户端是否给我发送数据。当我们获知新的链接的同时,客户端给我们发送了数据我们是无法实时的知道的,客户端发过来的数据此时还暂时的存储在内核read缓冲区中。
int main(){
int lfd = socket();//创建一个用于监听的套接字
bind(); //绑定
listen(); //监听
// 创建一文件描述符表
fd_st reads, temp;
// 初始化
fd_zero(&reads);
// 监听的lfd加入到读集合
//监听的文件描述符不负责数据发送,那么他如何知道别人跟他建立链接了?
//listen函数处理SYN,这也算是收发数据。如果有连接请求,就会发到文件描述符的读缓冲区了,
//因此监听的文件描述符也需要内核帮我们去检测的。如果有新的连接,则会有数据
fd_set(lfd, &reads);
//获取最大的文件描述符
int maxfd = lfd;
while(1){
// 委托内核检测
//拷贝文件描述符表
temp = reads;
//调用select函数,获取有几个客户端发送了数据给我
int ret = select(maxfd+1, &temp, NULL, NULL, NULL);
// 判断监听描述符的标志位是否为1,如果是1,则说明有新连接
if(fd_isset(lfd, &temp)){
// 接受新连接(此时accept不会阻塞,因为进入这个if,说明真的有新连接)
int cfd = accept();
// cfd加入我们要检测的读集合
//读的是reads,因为reads存的是原始数据
fd_set(cfd, &reads);
// 新来的客户端连接的文件描述符有可能会是最大的
//因此要更新记录最大文件描述符的变量值maxfd
maxfd=maxfd<cfd ? cfd:maxfd;
}
// 不是有新连接,说明此时在通信
//遍历判断哪个文件描述符绑定的客户端发送了数据
for(int i=lfd+1; i<=maxfd;++i){
if(fd_isset(i, &temp){
int len = read();
//说明对方断开连接
if(len == 0){
// cfd 从读集合中删除
fd_clr( i, &reads);
}
write();
}
}
}
}
3.4.6 select实现的完整例子
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<string.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<ctype.h>
#include<sys/select.h>
#include<errno.h>
int main(int argc, char *argv[]){
if(argc<2){
printf("eg: ./a.out port \n");
exit(1);
}
struct sockaddr_in serv_addr;
socklen_t serv_len=sizeof(serv_addr);
int port=atoi(argv[1]);
//创建套接字
int lfd=socket(AF_INET, SOCK_STREAM, 0);
//初始化服务器
memset(&serv_addr, 0, serv_len);
//地址族
serv_addr.sin_family=AF_INET;
//监听本地所有IP
serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
//设置端口
serv_addr.sin_port=htons(port);
//绑定IP和端口
bind(lfd, (struct sockaddr*)&serv_addr, serv_len);
//设置同时监听的最大个数
listen(lfd, 36);
printf("Start accept .... \n");
//使用信号回收子进程pcb
//不然让父进程一直用wait循环等待,那父进程就什么都做不了了
struct sigaction act;
act.sa_handler=recyle;
act.sa_flags=0;
//初始化mask,不然局部变量里面有什么我们不确定
sigemptyset(&act.sa_mask);
//规定我们要接受的信号
sigaction(SIGCHLD, &act, NULL);
struct sockaddr_in client_addr;
socklen_t cli_len=sizeof(client_addr);
//最大文件描述符
int maxfd=lfd;
//文件描述符读集合
fd_set reads, temp;
//初始化reads为0
FD_ZERO(&reads);
//把lfd放在读集合里,让内核帮忙检测是否有新的连接请求
FD_SET(lfd, &reads);
while(1){
//委托内核做IO检测
temp=reads;
//select函数是跨平台的,在linux下,第一个参数一定要写对
//在windows下,第一个参数你写-1,写谁都行
int ret=select(maxfd+1, &temp, NULL, NULL, NULL);
if(ret==-1){
perror("select error");
exit(1);
}
//有客户端发起新连接
if(FD_ISSET(lfd, &temp)){
//返回值是1,说明有新的连接
//接受连接请求——accept不阻塞
int cfd=accept(lfd,
(struct sockaddr*)&client_addr, &cli_len);
if(cfd==-1){
perror("accept error");
exit(1);
}
char ip[64];
printf("new client IP:%s, PORT: %d\n",
inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ip,sizeof(ip)),
ntohs(client_addr.sin_port));
//将cfd加入到待检测的读集合,这样下一次就能检测到了
FD_SET(cfd, &reads);
//更新最大描述符
maxfd=maxfd<cfd?cfd:maxfd;
}
//已经连接上的客户端给我发送了数据
//遍历read,去看
for(int i=lfd+1; i<=maxfd; ++i){
//判断temp,因为temp是被内核修改过的表
if(FD_ISSET(i, &temp)){
char buf[1024]={0};
int len=recv(i, buf, sizeof(buf), 0);
if(len==-1){
perror("recv error");
exit(1);
}else if(len ==0){
printf("客户端已经断开连接\n");
close(i);
//从读集合删除
FD_CLR(i, &reads);
}else{
//正常的手法数据
printf("recv buf: %s\n", buf);
//把数据发出去
//不把\0发出去的前提条件是buf已经被初始化了
//如果buf没初始化,则需要把\0发出去,否则接收端
//收到的数据的后边是乱码
send(i, buf, strlen(buf), 0);
}
}
}
}
close(lfd);
return 0;
}
3.5 poll
poll可以突破1024,因为他内部实现应用的是结构体数组。
select有读写异常者三种行为,三种行为做了三张表。poll把读写异常者三种状态封装到一个结构体里面,将三张表融合了。
3.5.1 poll相关函数
函数原型(select的和poll的):
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 实际发生的事件 */
};
int poll(struct pollfd *pollfd, //结构体数组首地址,用数组,因为链接客户端的文件描述符不能只是一个
nfds_t nfds, //被typedef重新定义的数据类型,看成int类型就可以,功能和select的nfds差不多
int timeout);
参数:
pollfd -- 数组的地址
nfds: 数组的最大长度, 数组中最后一个使用的元素下标+1
内核会轮询检测fd数组的每个文件描述符
timeout:
-1: 永久阻塞
0: 调用完成立即返回
>0: 等待的时长毫秒
返回值: IO发生变化的文件描述符的个数
你想监听哪个事件(读写异常),就把相应的时间赋值给结构体的events变量。
如果你读写异常三个都想监听,该如何设置?
模仿open函数,利用按位或,形如:
open("a.txt,O_WRONLY | O_APPEND");
short是两个字节,16位,我们对相应的位赋值即可,不用像select一样去弄很多表。
内核给的反馈也赋值给了结构体。
3.5.2 poll的实现代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <ctype.h>
#include <poll.h>
#define SERV_PORT 8989
int main(int argc, const char* argv[]){
int lfd, cfd;
struct sockaddr_in serv_addr, clien_addr;
int serv_len, clien_len;
// 创建套接字
lfd = socket(AF_INET, SOCK_STREAM, 0);
// 初始化服务器 sockaddr_in
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET; // 地址族
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听本机所有的IP
serv_addr.sin_port = htons(SERV_PORT); // 设置端口
serv_len = sizeof(serv_addr);
// 绑定IP和端口
bind(lfd, (struct sockaddr*)&serv_addr, serv_len);
// 设置同时监听的最大个数
listen(lfd, 36);
printf("Start accept ......\n");
// poll结构体
struct pollfd allfd[1024];
//最后一个有效元素下标
int max_index = 0;
// 初始化数组,fd的值为-1,说明没被占用
for(int i=0; i<1024; ++i){
allfd[i].fd = -1;
}
//把用于监听的描述符加入到待检测的数组里面
allfd[0].fd = lfd;
//初始化事件 POLLIN指的是读事件
allfd[0].events = POLLIN;
while(1){
int i = 0;
int ret = poll(allfd, max_index+1, -1);
if(ret == -1){
perror("poll error");
exit(1);
}
// 判断是否有连接请求
//按位与操作,16位的数如果里面有POLLIN,则&的结果为1
if(allfd[0].revents & POLLIN){
clien_len = sizeof(clien_addr);
// 接受连接请求
int cfd = accept(lfd, (struct sockaddr*)&clien_addr, &clien_len);
printf("============\n");
// cfd添加到poll数组
for(i=0; i<1024; ++i){
if(allfd[i].fd == -1){
allfd[i].fd = cfd;
break;
}
}
// 更新最后一个元素的下标
max_index = max_index < i ? i : max_index;
}
//有客户端发数据了,则遍历数组来接收数据
for(i=1; i<=max_index; ++i){
int fd = allfd[i].fd;
//说明此位置是无效位置
if(fd == -1){
continue;
}
if(allfd[i].revents & POLLIN){
// 接受数据
char buf[1024] = {0};
int len = recv(fd, buf, sizeof(buf), 0);
if(len == -1){
perror("recv error");
exit(1);
}else if(len == 0){
allfd[i].fd = -1;
close(fd);
printf("客户端已经主动断开连接。。。\n");
} else {
printf("recv buf = %s\n", buf);
for(int k=0; k<len; ++k){
//小写转大写
buf[k] = toupper(buf[k]);
}
printf("buf toupper: %s\n", buf);
send(fd, buf, strlen(buf)+1, 0);
}
}
}
}
close(lfd);
return 0;
}