在Linux中,I/O复用是一种高效的多路复用技术,用于同时监控多个文件描述符的状态(如是否可读、可写、发生错误等)。常用的I/O复用函数有三种:select
、poll
和 epoll
。本篇博客,我们详细介绍这三个方法的使用。
目录
2.3 使用 select 实现 TCP 服务器-客户端通信
一、引入背景
1.1 需要解决的问题
通过前面的学习,我们知道使用TCP协议的服务器客户端通信,无法同时与多个客户端建立连接进而通信,原因在于:recv()会进行阻塞,导致内层的循环一直是死循环出不去,因此外面的accept()无法与其他客户端建立连接,如下图所示:
这就是tcp服务器的并发运行处理,我们当时是通过多线程或者多进程解决的,但是存在一个缺点:它只适用于客户端较少(几十几百)的情况 ,当客户端比较庞大时就不行了(几万)。IO复用它利用一个进程就可以处理多线程并发,可以同时监听多个描述符,当哪一个描述符有数据,那么就会通知服务器去处理该描述符上的数据!这样就不会发生recv()阻塞,导致其他客户端无法连接的问题。把recv()的阻塞问题进行了转化为一个监听方法来替代解决!如下图所示:
基本思路:把所有文件描述符都放在一个集合中,把这个集合传给select()函数,select()一直循环检测哪个描述符会传数据过来(客户端),当select()的返回值大于0,就知道集合中有几个描述符有读事件发生,就去处理集合中对应的描述符,当有新的客户端连接时,我们将此客户端产生的描述符加入到集合中,当对应的描述符当客服端关闭后,也就是断开连接,我们会把这个客户端对应的描述符从集合中移除,所以这个集合是动态变化的。一开始只有一个监听套接字描述符,后面随着客户端的变化,集合里面的描述符也是动态变化的!
sockfd 监听套接字的读事件发生主要是:客户端执行connect()连接服务器,此时会产生新的描述符,我们要将新的描述符加入到集合中,进行监听。c 连接套接字的读事件发生主要为:客户端send()发送数据,通知服务器去相应的描述符读取数据,客户端close()关闭,当我们服务器端执行recv()的时候,返回值为0,说明客户端关闭,此时就要将对应的描述符从集合移除。
1.2 IO复用介绍
I/O 复用使得程序能同时监听多个文件描述符,这对于提高程序的性能至关重要。通常, 网络程序在下列情况下需要使用 I/O 复用技术:
- ◼ TCP 服务器同时要处理监听套接字和连接套接字。
- ◼ 服务器要同时处理 TCP 请求和 UDP 请求。
- ◼ 程序要同时处理多个套接字。
- ◼ 客户端程序要同时处理用户输入和网络连接。
- ◼ 服务器要同时监听多个端口。
注意:需要指出的是,I/O 复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依处理其中的每一 个文件描述符,这使得服务器看起来好像是串行工作的。如果要提高并发处理的能力,可以 配合使用多线程或多进程等编程方法。
二、select方法
2.1 select 的接口介绍
select 系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符的可读、 可写和异常等事件。
#include <sys/select.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
selecst 成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪,select 将返回 0。elect 失败是返回-1.如果在 select 等待 期间,程序接收到信号,则 select 立即返回-1,并设置 errno 为 EINTR。
select() 第一个参数是描述符最大值加一,第二个是读事件,第三个是写事件,第四个是异常事件,第五个参数是超时时间。 为什么是最大值加1,因为最大值之后面的位都是0,我们不用管,只有监听前面的。5这个描述符,在数组第六号下标中。如下图所示:
只要发送缓冲区有空间,写事件就能就绪,一开始发送缓冲区就有空间,所以一开始写事件就是就绪的;一开始我们接收缓冲区没有数据,无法recv()会阻塞,所以一开始读事件是没有发生的。集合其实就是数组,数组大小是1024位,一个位存放一个描述符,因此,它可以存放的描述符的范围是0-1023。每个位固定存那个描述符的,把描述符加到集合中,把描述符的值当作偏移量,找到对应的位把它从0置为1。这样就将描述符加入到了集合中。当有描述符发生读事件时,select()先把集合中发生读事件描述符对应的位从0置为1,然后监听到数据了,再把发生读事件的有数据的描述符位保留下来,没数据的描述符位在置为0.如下图所示:
最后一个参数是超时时间,timeout 参数用来设置 select 函数的超时时间。它是一个 timeval 结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序 select 等待了多久。timeval 结构的定义如下:
struct timeval
{
long tv_sec; //秒数
long tv_usec; // 微秒数
};
如果给 timeout 的两个成员都是 0,则 select 将立即返回。如果 timeout 传递
NULL,则 select 将一直阻塞,直到某个文件描述符就绪
为方便对集合中的描述符进行操作,有如下函数:
FD_ZERO(fd_set *fdset); // 清除 fdset 的所有位
FD_SET(int fd, fd_set *fdset); // 设置 fdset 的位 fd
FD_CLR(int fd, fd_set *fdset); // 清除 fdset 的位 fd
int FD_ISSET(int fd, fd_set *fdset);// 测试 fdset 的位 fd 是否被设置1,也就是该描述符是否发生事件
2.2 使用示例
检测键盘描述符,标准输入流,其描述符为0.
2.3 使用 select 实现 TCP 服务器-客户端通信
使用 select 实现的 TCP 服务器代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/select.h>
#include <time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAXFD 10
int socket_init();
void fds_init(int fds[])
{
for(int i = 0; i < MAXFD; i++ )
{
fds[i] = -1; /*代表这个数组没有有效描述符,因为描述符都是大于等于0的*/
}
}
void fds_add(int fds[], int fd)
{
for(int i = 0; i < MAXFD; i++ )
{
if ( -1 == fds[i] )
{
fds[i] = fd;
break;
}
}
}
void fds_del(int fds[], int fd)
{
for(int i = 0; i < MAXFD; i++ )
{
if ( fd == fds[i] )
{
fds[i] = -1;
break;
}
}
}
/*处理监听套接字accept()*/
void accept_client(int fds[], int sockfd)
{
int c = accept(sockfd,NULL,NULL);
if (c < 0 )
{
return;
}
printf("accept c=%d\n",c);
fds_add(fds,c); //将新的连接套接字加入到数组中,后面进入while(1)大循环时,会再次将变化后的存放描述符的数组加入到集合中
}
/*处理连接套接字recv()*/
void recv_data(int fds[], int c)
{
char buff[128] = {0};
int n = recv(c,buff,127,0);
if ( n <= 0 )
{
close(c);
fds_del(fds,c);
printf("client close\n");
return ;
}
printf("buff(%d)=%s\n",c,buff);
send(c,"ok",2,0);
}
int main()
{
int sockfd = socket_init(); //创建监听套接字
if ( -1 == sockfd )
{
exit(1);
}
int fds[MAXFD]; //定义存放描述符的数组,只能存放10个描述符
fds_init(fds); //一开始没有有效描述符,将数组初始化为-1
fds_add(fds,sockfd); //将目前唯一有效的监听套接字描述符加入到数组中
fd_set fdset;
while( 1 )
{
FD_ZERO(&fdset); //清空集合
int maxfd = -1;
/*将数组中的有效描述符加入到集合中*/
for(int i = 0; i < MAXFD; i++ )
{
if ( -1 == fds[i] )
{
continue; //无效描述符不用处理,不需要加入到集合中
}
FD_SET(fds[i],&fdset); //将数组中的有效描述符加入到集合中
if( fds[i] > maxfd )
{
maxfd = fds[i]; //找到所有描述符中最大的那个
}
}
struct timeval tv = {5,0};
int n = select(maxfd+1,&fdset,NULL,NULL,&tv); //可能阻塞
if ( -1 == n )
{
printf("select err\n");
}
else if ( 0 == n )
{
printf("time out\n");
}
else
{
/*返回值n大于零(有多个套接字发生读事件),需要找到这n个描述符*/
for(int i = 0; i < MAXFD; i++ )
{
if ( -1 == fds[i] )
{
continue; /*无效描述符*/
}
if ( FD_ISSET(fds[i],&fdset) ) /*判断该描述符对应的位是否被设置,也就是这个描述符是否发生读事件,也就是客户端是否连接服务器或者给服务器发数据*/
{
if ( fds[i] == sockfd ) //监听套接字有读事件发生,建立连接accept()
{
accept_client(fds,sockfd);
}
else
{
recv_data(fds,fds[i]); //连接套接字有读事件发生,接收数据recv()
}
}
}
}
}
}
int socket_init()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if ( -1 == sockfd )
{
return -1;
}
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
if ( -1 == res )
{
printf("bind err\n");
return -1;
}
res = listen(sockfd,5);
if ( -1 == res )
{
return -1;
}
return sockfd;
}
TCP 的客户端代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
assert(sockfd != -1); // 确认套接字创建成功
struct sockaddr_in saddr; // 定义服务器的地址结构
memset(&saddr, 0, sizeof(saddr)); // 将服务器地址结构清零
saddr.sin_family = AF_INET; // 设置地址族为AF_INET
saddr.sin_port = htons(6000); // 设置端口号为6000,并转换为网络字节序
saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 设置IP地址为127.0.0.1
int res = connect(sockfd, (struct sockaddr*)&saddr, sizeof(saddr)); // 连接到服务器
assert(res != -1); // 确认连接成功
while (1)
{
char buff[128] = {0}; // 用于存储用户输入的缓冲区
printf("input:\n"); // 提示用户输入
fgets(buff, 128, stdin); // 从标准输入获取用户输入
if (strncmp(buff, "end", 3) == 0) // 如果用户输入"end",则退出循环
{
break;
}
send(sockfd, buff, strlen(buff), 0); // 发送用户输入的数据到服务器
memset(buff, 0, 128); // 清空缓冲区
recv(sockfd, buff, 127, 0); // 接收服务器的响应
printf("buff=%s\n", buff); // 打印服务器的响应
}
close(sockfd); // 关闭套接字
exit(0); // 退出程序
}
如果recv()一次只接受一个数据,当我们接收缓冲区还有数据时,这个连接套接字就一直会有读事件,recv()不会阻塞,知道把数据读完为止。
三、poll方法
poll 系统调用和 select 类似,也是在指定时间内轮询一定数量的文件描述符,以测试其 中是否有就绪者。
3.1 poll接口介绍
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll 系统调用成功返回就绪文件描述符的总数,超时返回 0,失败返回-1,nfds 参数指定被监听事件集合 fds 的大小。 timeout 参数指定 poll 的超时值,单位是毫秒,timeout 为-1 时,poll 调用将永久阻塞,直到某个事件发生,timeout 为 0 时,poll 调用将立即返回。fds 参数是一个 struct pollfd 结构类型的数组,它指定所有用户感兴趣的文件描述 符上发生的可读、可写和异常等事件。pollfd 结构体定义如下:
struct pollfd { int fd; // 文件描述符 short events; // 注册的关注事件类型 short revents; // 实际发生的事件类型,由内核填充 };
其中,fd 成员指定文件描述符,events 成员告诉 poll 监听 fd 上的哪些事件类型。 它是一系列事件的按位或,revents 成员则有内核修改,通知应用程序 fd 上实际发 生了哪些事件。poll 支持的事件类型如下
POLLIN读事件, POLLOUT写事件;读事件是接收缓冲区有数据了就有事件发生,写事件是发送缓冲区只要空着,还有空间,就会有事件发生,所以一开始发送缓冲区空着就会有写事件发生。这个写事件是应用于发送大量数据,但发送缓冲区没有呢么大,如果发送缓冲区满着,send就会阻塞住,所以可以用写事件的发生来检测发送缓冲区是否有空间可以写数据。
如何知道该事件发生?
因为struct pollfd结构体的第二个成员和第三个成员是一个短整型,用一位表示相应的事件,所以我们可以用实际发生的事件&用户关心的事件,看结果是否为真,revent&POLLIN。
3.2 使用 poll实现 TCP 服务器-客户端通信
使用 poll 实现的 TCP 服务器代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <poll.h>
#include <time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAXFD 10
int socket_init();
void fds_init(struct pollfd fds[] )
{
for(int i = 0; i < MAXFD; i++ )
{
fds[i].fd = -1;
fds[i].events = 0; //不关注任何事件
fds[i].revents = 0;
}
}
//将有效描述符添加到结构体数组
void fds_add(struct pollfd fds[], int fd)
{
for(int i = 0; i < MAXFD; i++ )
{
if ( -1 == fds[i].fd )
{
fds[i].fd = fd;
fds[i].events = POLLIN; //关注读事件
fds[i].revents = 0; //这个由内核进行填充,我们清零即可
break;
}
}
}
//移除结构体数组中断开连接的描述符
void fds_del(struct pollfd fds[], int fd)
{
for(int i = 0; i < MAXFD; i++ )
{
if ( fds[i].fd == fd )
{
fds[i].fd = -1;
fds[i].events = 0;
fds[i].revents = 0;
break;
}
}
}
监听套接字处理读事件,accept()
void accept_client(struct pollfd fds[], int sockfd)
{
int c = accept(sockfd,NULL,NULL);
if ( c < 0 )
{
return ;
}
printf("accept c = %d\n",c);
fds_add(fds,c);
}
//连接套接字处理读事件,recv()
void recv_data(struct pollfd fds[], int c)
{
char buff[128] = {0};
int n = recv(c,buff,1,0);
if ( n <= 0 )
{
fds_del(fds,c);
close(c); //断开连接必须关掉该描述符(三次握手,四次挥手),文件描述符是有限的
printf("client close\n");
return;
}
printf("recv(%d)=%s\n",c,buff);
send(c,"ok",2,0);
}
int main()
{
int sockfd = socket_init();
if ( -1 == sockfd )
{
exit(1);
}
struct pollfd fds[MAXFD]; //定义结构体数组收集描述符, 结构体里面有三个成员
fds_init(fds); //整个数组为空,没有有效描述符
fds_add(fds,sockfd); //将监听套接字添加到结构体数组中
while( 1 )
{
int n = poll(fds,MAXFD,5000); //第一个参数为结构体数组,第二个为数组大小,第三个为超时时间,-1代表一直阻塞,0代表立即返回,5000毫秒阻塞时间
if ( n == -1 )
{
printf("poll err\n");
}
else if( n == 0 )
{
printf("time out\n");
}
else
{
for(int i = 0; i < MAXFD; i++ )
{
if ( fds[i].fd == -1 )
{
continue;
}
if ( fds[i].revents & POLLIN ) //判断关心的事件是否发生
{
if ( sockfd == fds[i].fd )
{
accept_client(fds,sockfd);
}
else
{
recv_data(fds,fds[i].fd);
}
}
//if ( fds[i].revent & POLLOUT )
}
}
}
}
int socket_init()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if ( -1 == sockfd ) /*创建套接字其实就是打开了一个文件,所以叫套接字文件描述符,它是文件表的下标:0 1 2...不可能是负数*/
{
return -1;
}
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
if ( -1 == res )
{
printf("bind err\n");
return -1;
}
res = listen(sockfd,5);
if ( -1 == res )
{
return -1;
}
return sockfd;
}
TCP 的客户端代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
assert(sockfd != -1); // 确认套接字创建成功
struct sockaddr_in saddr; // 定义服务器的地址结构
memset(&saddr, 0, sizeof(saddr)); // 将服务器地址结构清零
saddr.sin_family = AF_INET; // 设置地址族为AF_INET
saddr.sin_port = htons(6000); // 设置端口号为6000,并转换为网络字节序
saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 设置IP地址为127.0.0.1
int res = connect(sockfd, (struct sockaddr*)&saddr, sizeof(saddr)); // 连接到服务器
assert(res != -1); // 确认连接成功
while (1)
{
char buff[128] = {0}; // 用于存储用户输入的缓冲区
printf("input:\n"); // 提示用户输入
fgets(buff, 128, stdin); // 从标准输入获取用户输入
if (strncmp(buff, "end", 3) == 0) // 如果用户输入"end",则退出循环
{
break;
}
send(sockfd, buff, strlen(buff), 0); // 发送用户输入的数据到服务器
memset(buff, 0, 128); // 清空缓冲区
recv(sockfd, buff, 127, 0); // 接收服务器的响应
printf("buff=%s\n", buff); // 打印服务器的响应
}
close(sockfd); // 关闭套接字
exit(0); // 退出程序
}
查看一个进程打开了哪些文件: lsof -p 进程pid 能查看该进程连接了那些客户端
uname -a 查看内核版本
四、epoll方法
epoll 是 Linux 特有的 I/O 复用函数。它在实现和使用上与 select、poll 有很大差异。首先,epoll 使用一组函数来完成任务,而不是单个函数。其次,epoll 把用户关心的文件描述 符上的事件放在内核里的一个事件表中。从而无需像 select 和 poll 那样每次调用都要重复传入文件描述符或事件集。但 epoll 需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。
epoll 相关的函数如下:
- ◼ epoll_create()用于创建内核事件表
- ◼ epoll_ctl()用于操作内核事件表
- ◼ epoll_wait()用于在一段超时时间内等待一组文件描述符上的事件
4.1 epoll方法接口介绍
int epoll_create(int size);
epoll_create() //创建内核事件表,主要是用来收集描述符,它创建在内核中,但是并不是像poll那样的一个数组,在这里用的是红黑树来存放描述符,一个结点收集一个描述符,每加入一个描述符,就向树添加一个结点。epoll_create()成功返回内核事件表的文件描述符,失败返回-1,参数是创建内核事件表的大小(用于指定这个
epoll
实例预计要处理的文件描述符数量),也就是红黑树的大小,但大小没意义就像链表一样需要一个就申请一个,但我们在传入这个参数的时候要保证大于0。返回值也是一个文件描述符,我们把创建红黑树抽象成打开一个文件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl() //向内核事件表注册,删除,修改描述符和关心的事件,本质上就是向这棵红黑树上插入、删除结点,也就是添加描述符和移除描述符,函数返回0代表成功,返回-1代表失败。第一个参数是内核时间表的文件描述符,第二个参数是添加事件/修改事件/删除事件,第三个参数是添加的文件描述符,第四个参数是一个结构体,里面存放发生的事件和文件描述符。
struct epoll_event { uint32_t events; /* 事件 */ epoll_data_t data; /* data 是一个联合体,里面的成员fd存放文件描述符 */ }; typedef union epoll_data { void *ptr; int fd; //描述符 uint32_t u32; uint64_t u64; } epoll_data_t;
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_wait() //这就是内核帮我们检测有哪些描述符就绪,告诉用户,它肯能会阻塞(没有描述符就绪),获取就绪描述符,存放在自己的一个参数(数组)中,返回给我们,因此,它不需要像poll那样去遍历所有的描述符,可以直接拿到就绪的所有描述符,这也是它比poll高效的一个原因。第一个参数是内核事件表的描述符,第二个参数是结构体数组struct epoll_event用来存放所有就绪的描述符, 第三个参数是结构体数组大小,第四个参数是超时时间,以毫秒为单位。
内核会帮我们创建两个东西: rbr 红黑树,收集描述符 ;rdlist 链表,存放就绪描述符。当我们创建好红黑树后,只要添加描述符,就向红黑树添加一个结点存放该描述符,内核开始检测描述符,当有就绪描述符时,就把就绪描述符放到rdlist链表里,epoll_wait()就一直盯着rdilist链表,如果rdlist链表不为空,则代表有就绪描述符,就把绪描述符拷出来,给用户。
4.2 使用epoll实现的 TCP 服务器-客户端通信
使用 epoll 实现的 TCP 服务器代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#define MAXFD 10
//向内核事件表添加一个描述符
void epoll_add(int epfd, int fd)
{
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN; //关注读事件
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1)
{
printf("epoll add err\n");
}
}
//删除内核事件表中的一个描述符
void epoll_del(int epfd, int fd)
{
if (epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL) == -1)
{
printf("epoll del err\n")
}
}
//处理监听套接字
void accept_client(int epfd, int sockfd)
{
int c = accept(sockfd, NULL, NULL);
if (c < 0)
{
return;
}
printf("accept c=%d\n", c);
epoll_add(epfd, c);
}
//处理连接套接字
void recv_data(int epfd, int c)
{
char buff[128] = { 0 };
int ∩um = recv(c, buff, 127, 0);
if (num <= 0)
{
epoll_del(epfd, c);
close(c);
printf("client close\n");
return;
}
printf("recv(%d)=%s\n", c, buff);
send(c, "ok", 2, 0);
}
int main()
{
int sockfd = socket_init();
if (-1 == sockfd)
{
exit(1);
}
int epfd = epoll_create(MAXFD); //创建内核事件表 --->红黑树
if (-1 == epfd)
{
exit(1);
}
//将监听套接字描述符加入到内核事件表
epoll_add(epfd, sockfd);
struct epoll_event evs[MAXFD];//存放就绪的套接字描述符的结构体数组
while (1)
{
int ∩ = epoll_wait(epfd, evs, MAXFD, 5000); //获取就绪描述符
if (∩ == -1)
{
printf("epoll wait err\n");
}
else if(∩ == 0)
{
printf("time out\n");
}
else
{
for (int i = 0; i < n; i++)
{
if (evs[i].events & EPOLLIN) //读事件发生
{
if (evs[i].data.fd == sockfd)
{
accept_client(epfd, sockfd);
}
else
{
recv_data(epfd, evs[i].data.fd);
}
}
}
}
}
int socket_init()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd) /*创建套接字其实就是打开了一个文件,所以叫套接字文件描述符,它是文件表的下标:0 1 2...不可能是负数*/
{
return -1;
}
struct sockaddr_in saddr;
memset(&saddr, 0, sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd, (struct sockaddr*)&saddr, sizeof(saddr));
if (-1 == res)
{
printf("bind err\n");
return -1;
}
res = listen(sockfd, 5);
if (-1 == res)
{
return -1;
}
return sockfd;
}
TCP 的客户端代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
assert(sockfd != -1); // 确认套接字创建成功
struct sockaddr_in saddr; // 定义服务器的地址结构
memset(&saddr, 0, sizeof(saddr)); // 将服务器地址结构清零
saddr.sin_family = AF_INET; // 设置地址族为AF_INET
saddr.sin_port = htons(6000); // 设置端口号为6000,并转换为网络字节序
saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 设置IP地址为127.0.0.1
int res = connect(sockfd, (struct sockaddr*)&saddr, sizeof(saddr)); // 连接到服务器
assert(res != -1); // 确认连接成功
while (1)
{
char buff[128] = {0}; // 用于存储用户输入的缓冲区
printf("input:\n"); // 提示用户输入
fgets(buff, 128, stdin); // 从标准输入获取用户输入
if (strncmp(buff, "end", 3) == 0) // 如果用户输入"end",则退出循环
{
break;
}
send(sockfd, buff, strlen(buff), 0); // 发送用户输入的数据到服务器
memset(buff, 0, 128); // 清空缓冲区
recv(sockfd, buff, 127, 0); // 接收服务器的响应
printf("buff=%s\n", buff); // 打印服务器的响应
}
close(sockfd); // 关闭套接字
exit(0); // 退出程序
}
五、select、poll、epoll三者的区别(面试题)
总结:
- select 是要先有一个数组来存描述符,因为select会把集合里有事件发生的描述符保留,没事件发生的描述符会置为0,select只能监测读事件。select只能监测1024个描述符,因为他的集合就这么大。
- poll 是直接定义一个集合,集合是struct pollfd结构体,直接用poll监测这个集合,看哪些描述符有事件发生,poll能监测读事件,写事件,连接关闭事件。poll连接是文件描述符,一个进程能打开的文件描述符可以修改,所以>1024.
- epoll 是要先定义一个内核事件表,内核事件表里是要监测的描述符,还要准备一个struct poll_event结构体数组,相当于是,内核事件表有就绪事件发生,内核先把有事件发生的描述符拷贝到一个链表中,在通过epoll_wait把链表中的就绪描述符拷贝到用户的这个结构体数组。epoll会直接把事件发生的描述符提供给你,不需要再去遍历查找。epoll是1G能有10w个连接,只要内存够。
- select关注的事件只有读、写、异常,poll和epoll能关注更多的事件;
- select、poll windows也有,只有epoll是linux特有的,当处理较多的描述符时,unix操作系统的 io复用j技术是kqueue,windows操作系统 io复用技术是完成端口iocp。
- select和poll每次循环都要把收集描述符的集合传给内核让内核检测是否有事件发生,epoll内核只把每个描述符加入一次到内核事件表而不是每次都要将所有描述符加入到内核事件表,因为我们收集描述符的内核事件表,创建在内核上了,因此这也是epoll高效的另一个原因。
select poll epoll都具有LT模式,而只有epoll有ET模式;
六、LT 和 ET 模式(面试题)
6.1 概念
在 Linux 的 I/O 复用机制中,LT(Level-Triggered)和 ET(Edge-Triggered)是两个重要的触发模式,主要用于描述事件通知的方式。I/O 复用是通过 select
、poll
和 epoll
等系统调用实现的,而这些触发模式主要应用在 epoll
中。
Level-Triggered (LT) 模式
LT 模式是
epoll
的默认模式,类似于select
和poll
的行为。它的工作原理如下:
- 事件通知:当文件描述符(如套接字、管道等)上有事件发生(如可读、可写)时,内核会通知应用程序。
- 持续通知:只要文件描述符上有事件未处理完毕,每次调用
epoll_wait
都会通知应用程序。这意味着如果一个文件描述符在第一次通知后没有被处理完,后续的epoll_wait
调用仍会再次通知。优点:
- 编程简单,行为直观。
- 避免遗漏事件,因为事件没有被处理完会不断通知。
缺点:
- 在高并发场景下,可能会导致重复通知,增加系统开销。
Edge-Triggered (ET) 模式
ET 模式是一种更高效但也更复杂的事件通知方式,只有在状态变化时才会通知应用程序:
- 事件通知:当文件描述符的状态发生变化时(如从不可读变为可读),内核会通知应用程序。
- 一次性通知:只有在状态改变时才会通知,且仅通知一次。如果文件描述符在处理事件后没有再次变为不可读或者不可写,后续的
epoll_wait
调用不会再次通知。所以 ET 模式在很大程度上降低了同一个 epoll 事件被重复触发的次数,因 此效率比 LT 模式高。优点:
- 减少系统调用次数,提高性能,适用于高并发、高性能场景。
- 更少的重复通知,降低应用程序的负担。
缺点:
- 编程复杂度高,开发者需要确保在每次通知时尽可能地读取或写入所有数据,否则可能会遗漏事件,导致文件描述符处于未处理状态而不再收到通知。
举个生活中的例子便于理解:快递站有你的5个包裹,你只取了一个,然后快递站会一直发短信给你通知,让你去取快递,这就是LT模式,而ET模式就是:5个包裹快递站给你只发了一次短信,你只取走了一个,后面的四个还存放在快递站(接收缓冲区),这就是ET模式。
6.2 ET模式的理解
如何将epoll方法修改成ET模式:在向内核事件表添加描述符的时候,我们会设置关心的事件,加入ET模式即可。
连接服务器和客户端,发送数据如下:
上述出现的原因是:使用的是TCP协议,IO方法只是检测该描述符是否产生读事件(客户端发送数据),如果有,则通知服务器端去读取数据,数据全部存放在接收缓冲区,不会修改缓冲区的数据。
6.3 ET模式下只通知一次便将数据读取完毕
如何让ET模式,一次把数据读完?
1、 描述符设置为非阻塞 循环
正常情况下,如果接收缓冲区没有数据了,我们在recv(),会阻塞住
<fcntl.h> fcntl()设置描述符为非阻塞
2、 recv 返回值-1,判断 错误码
非阻塞状态下,ET模式虽然只通知我们一次去读取接收缓冲区数据,但是recv()去从接收缓冲区读取的时候(每次只读取一个字节),他就不会阻塞了,因为缓冲区有数据,它会一直读取,直到将缓冲区数据读完,我们设置成非阻塞状态,接收缓冲区没有数据,再去读不会阻塞,会返回-1,但返回-1有很多种情况,那么怎么判断读完呢?然后退出呢?有一种错误码是非阻塞情况下,没有数据可读,返回的错误码,错误码会告诉我们,这次是由于什么原因引起的,errno==EAGAIN or EWOULDBLOCK,引用头文件<errno.h>当我们出错的时候,就会有全局变量errno记录我们的错误码。
注意:recv()读取失败的情况有很多种,比如客户端关闭,读取失败,recv返回0,缓冲区没有数据,recv()返回-1
void recv_data(int epfd, int c)
{
//ET模式:循环读取,非阻塞,根据recv的返回值进行判断
while( 1 )
{
char buff[128] = {0};
int n = recv(c,buff,1,0);
if ( n == -1 )
{
if ( EAGAIN == errno || EWOULDBLOCK == errno )
{
send(c,"ok",2,0);
}
else
{
printf("recv err\n");
}
break;
}
else if ( n == 0 )
{
epoll_del(epfd,c);
close(c);
printf("client close\n");
break;
}
else
{
printf("buff=%s\n",buff);
}
}
// LT模式:只要接收缓冲区有数据,他就会一直通知服务器去处理数据,直到处理完毕
char buff[128] = {0};
int n = recv(c,buff,1,0);
if ( n <= 0 )
{
epoll_del(epfd,c);
close(c);
printf("client close\n");
return;
}
printf("buff(%d)=%s\n",c,buff);
send(c,"ok",2,0);
}
面试题:ET模式为什么要设置成非阻塞?
ET模式与LT(Level Triggered)模式不同,ET模式下内核只在状态发生变化时通知你一次。例如,当有新数据到达时,内核会通知你套接字变为可读状态,但是在你处理这个通知后,即使还有未读取的数据,内核也不会再次通知你,除非新的数据到达。
为什么需要非阻塞模式
一次性处理所有数据:
- 在ET模式下,当你收到可读事件时,可能存在多次读取操作才能读取完所有数据。如果使用阻塞模式,一旦数据读取完毕且没有新数据到达,读取操作会阻塞,导致无法继续处理其他事件。
- 使用非阻塞模式可以避免这种情况:当没有数据可读时,
read
或recv
函数会立即返回,而不是阻塞等待新数据。避免丢失事件:
- 如果你在处理通知时没有读取所有数据,ET模式下不会再次通知你,这会导致数据未被及时读取。如果使用非阻塞模式,你可以在一个通知中循环读取所有数据,直到
read
或recv
函数返回-1,并且errno
为EAGAIN
或EWOULDBLOCK
,表示所有数据已被读取。
七、EPOLLONESHOT 事件(面试题)
此事件是在多线程环境下,当某个描述符发生事件时,一个线程就去处理这个描述符,当这个描述符被正在处理过程中,它又有事件发生,此时就还会有别的线程去处理这个描述符,就会导致有两个线程在同一时刻处理相同的描述符,谁也拿不到完整的数据,因为他们共享文件偏移量。
解决方法:当描述符有事件发生,一个线程去处理这个描述符,内核就不再检测这个描述符,等到数据处理完了再去把这个描述符重置一下,内核就可以继续检测它。
至此,已经讲解完毕!篇幅较长,慢慢消化,以上就是全部内容!请务必掌握,创作不易,欢迎大家点赞加关注评论,您的支持是我前进最大的动力!下期再见!