一、多进程/多线程模型的不足
为每个请求分配一个进程或线程的方式会带来较大的资源开销。创建和切换进程/线程需要消耗系统资源,包括内存、CPU 时间等。例如,在一个大规模的服务器环境中,如果同时有数千个请求到来,为每个请求创建单独的进程或线程,可能会迅速耗尽系统的内存资源,导致系统性能下降甚至崩溃。而且,大量的进程/线程切换也会增加 CPU 的负担,降低整体的处理效率。
二、I/O 多路复用技术
I/O 多路复用技术允许一个进程同时监控多个 Socket 连接。虽然在某一时刻只能处理一个请求,但由于每个请求的处理时间极短,在单位时间内就能处理大量的请求。这种时分复用的方式类似于 CPU 对多个进程的并发处理。以一个在线游戏服务器为例,它可能同时接收来自众多玩家的连接请求,通过 I/O 多路复用,服务器能够高效地处理这些请求,而无需为每个玩家创建单独的进程或线程。
1、select、poll、epoll 内核提供的多路复用系统调用:
这些系统调用为进程提供了从内核获取多个事件的途径。进程可以通过调用相应的函数,将关注的连接(以文件描述符表示)传递给内核,内核会监控这些连接的状态变化,并将产生了事件的连接返回给进程。这使得进程能够集中处理有数据到达或可进行读写操作的连接,提高了系统的资源利用率和处理效率。
2、select、poll、epoll 获取网络事件的过程:
首先,应用程序将需要监控的所有连接的文件描述符传递给内核。内核会对这些连接进行监控。当有连接发生状态变化,比如有新数据到达、连接关闭等,内核会将这些产生了事件的连接的文件描述符返回给应用程序。应用程序在接收到这些信息后,就可以在用户态中对相应的连接进行处理,例如读取数据、发送响应等。
3、select、poll、epoll 能否实现 C10K:
- select:由于其存在文件描述符数量限制以及在每次调用时需要在用户态和内核态之间频繁复制大量文件描述符集合的问题,导致其在处理大规模并发连接时效率低下,较难实现 C10K。例如,在一个高并发的聊天应用中,如果使用 select 来处理大量的连接,可能会出现响应延迟、丢包等问题。
- poll:虽然没有了文件描述符数量的限制,但它在每次调用时仍需要复制大量的文件描述符集合,效率问题依然存在,要实现 C10K 也面临较大挑战。比如在一个大规模的电商网站中,大量用户同时发起请求,如果使用 poll 处理,可能会导致服务器性能下降,影响用户体验。
- epoll:采用了更高效的事件驱动方式,避免了上述的开销,能够轻松应对 C10K 甚至更高的并发连接需求。以一个热门的在线视频平台为例,使用 epoll 可以有效地处理大量观众同时观看视频时产生的并发连接,保证视频的流畅播放和用户的良好体验。
三、select 实现多路复用的方式
select 会把已连接的 Socket 放入一个文件描述符集合。这就像是把多个钥匙放在一个盒子里。当调用 select 函数时,会把这个装满钥匙(文件描述符)的盒子拷贝到内核中。内核就像一个检查官,通过逐个遍历盒子里的钥匙来检查是否有网络事件产生。一旦发现某个钥匙对应的 Socket 有事件,就给它做个可读或可写的标记。然后,又把整个盒子拷贝回用户态。回到用户态后,用户程序也得像内核那样,再次遍历这个盒子里的钥匙,找到有标记的、可读或可写的 Socket 进行处理。比如说,想象一个电商网站的服务器,同时处理大量的用户请求。select 就像是一个不太聪明的管理员,每次都要把所有的请求信息(文件描述符集合)搬进搬出内核,还得反复查看每个请求的状态。
1、select 的遍历和拷贝问题
对于 select 这种方式,需要进行两次对文件描述符集合的遍历。内核态里的遍历就像是在一个大仓库里逐个检查货物是否有问题;用户态里的遍历则像是在一堆已经检查过的货物中再次筛选出有问题的。而且,还会发生两次文件描述符集合的拷贝。这就好比把一箱子东西从一个房间搬到另一个房间,修改后又搬回来,这个过程既繁琐又耗时。以一个在线游戏的服务器为例,大量玩家同时在线,频繁的遍历和拷贝会导致服务器处理请求的速度变慢,玩家可能会感觉到卡顿。
2、select 的文件描述符限制
select 使用固定长度的 BitsMap 来表示文件描述符集合。这就好比一个固定大小的柜子,只能放一定数量的东西。在 Linux 系统中,它能处理的文件描述符个数由内核中的 FD_SETSIZE 限制,默认最大值为 1024,只能监听 0 到 1023 的文件描述符。假设我们有一个大型的金融交易系统,当并发连接数超过 1024 时,select 就无法有效处理,可能会导致部分交易请求被遗漏或延迟处理。
3、select服务端代码
cpp
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<pthread.h>
#include<fcntl.h>
#include<sys/socket.h>
#include<sys/select.h>
#include<netinet/ip.h>
#include<arpa/inet.h>
#define BUFSIZE 1024
#define SERROPT 8002
#define SERIP "192.168.117.129"
int main(int argc, char* argv[])
{
int lfd,cfd;
char buf[BUFSIZE];
socklen_t addrlen;
lfd= socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in seraddr, cliaddr;
seraddr.sin_family = AF_INET;
seraddr.sin_port = htons(SERROPT);
inet_pton(AF_INET, SERIP ,(void*)&seraddr.sin_addr.s_addr);
bind (lfd, (struct sockaddr*)&seraddr, sizeof(seraddr));
listen(lfd,128);
socklen_t len = sizeof(cliaddr);
//已经有了文件描述符监听
int nfds;
fd_set rset, aset;
FD_ZERO(&aset);
FD_SET(lfd,&aset);
nfds = lfd+1;
while(1)
{
rset = aset;
int ret = select(nfds,&rset,NULL,NULL,NULL);//上来先阻塞
if(ret == -1)
{
perror("select error");
exit(1);
}
printf("select返回\n");
if(FD_ISSET(lfd,&rset)) //lfd在rest集合里不会阻塞
{
int cfd = accept(lfd,(struct sockaddr*)&cliaddr,&len);
char dst[256];
printf( "client is ok cfd = %d, IP : %s port : %d successful connection!\n ", cfd,inet_ntop(AF_INET,&cliaddr.sin_addr.s_addr,dst,64),ntohs(cliaddr.sin_port));
FD_SET(cfd,&aset);
if((nfds-1) < cfd)
{
nfds = cfd+1;
}
}
for( int i = lfd + 1;i < nfds ; i++)
{
if(FD_ISSET(i,&rset))
{
int rr = read(i, buf,BUFSIZE);
if(rr < 0)
{
perror("read errro");
exit(1);
}
else if(rr == 0)
{
FD_CLR(i,&aset);
printf("客户端断开连接cfd = %d\n",i);
close(i);
}
else
{
write(STDERR_FILENO,buf,rr);
write(i,buf,rr);
}
}
}
}
return 0;
}
四、poll 对 select 的改进:
poll 不再使用 BitsMap 来存储关注的文件描述符,而是采用动态数组以链表形式来组织。这就像是从一个固定大小的柜子换成了可以无限延长的输送带,可以容纳更多的文件描述符,突破了 select 的个数限制。但就像前面说的,poll 和 select 在本质上还是很相似的。它们都使用"线性结构"来存储进程关注的 Socket 集合,所以都需要遍历整个集合来找到可读或可写的 Socket,时间复杂度都是 O(n)。并且,同样需要在用户态和内核态之间拷贝文件描述符集合。例如,在一个高并发的社交媒体平台上,如果使用 poll 或 select ,随着并发数的增加,系统性能的损耗会越来越大,可能会出现服务器响应缓慢、消息推送延迟等问题。
1、对poll的理解
poll 是对 select 的一种改进,主要解决了 select 在文件描述符数量上的限制问题。poll 不再使用 BitsMap 来存储所关注的文件描述符,而是采用动态数组以链表形式来组织。这意味着它能够处理的文件描述符数量不再像 select 那样受到固定大小的限制,而是更多地取决于系统资源和内存空间。在工作原理上,poll 和 select 有一定的相似性。
首先,应用程序需要将关注的文件描述符集合告知内核。与 select 不同的是,poll 使用的动态数组在添加或删除文件描述符时更加灵活。
然后,内核会对这些文件描述符进行监控,检查是否有网络事件产生。同样,这个检查过程也是通过遍历的方式进行的。当有事件发生时,内核会标记相应的文件描述符。
最后,内核将结果返回给用户态的应用程序。应用程序接收到结果后,仍需要通过遍历的方式找到发生事件的文件描述符,并进行相应的处理。
然而,尽管 poll 相较于 select 在文件描述符数量上有所改进,但它仍然存在一些不足之处。由于 poll 仍然采用线性结构来存储文件描述符集合,所以在处理大量文件描述符时,遍历的时间复杂度仍然是 O(n)。这意味着随着并发连接数量的增加,性能损耗会逐渐增大。另外,poll 和 select 一样,都需要在用户态和内核态之间拷贝文件描述符集合,这也会带来一定的性能开销。
总的来说,poll 虽然在一定程度上改进了 select 的不足,但在处理大规模并发连接时,其性能仍然可能无法满足需求。
2、poll服务端代码
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <poll.h>
#define SER_PORT 8000
#define MAXFD 1024
#define BUFSIZE 4096
int main(int argc, char* argv[])
{
int lfd, cfd;
struct sockaddr_in ser_addr, cli_addr;
socklen_t cli_addrlen = sizeof(cli_addr);
ser_addr.sin_family = AF_INET;
ser_addr.sin_port = htons(SER_PORT);
ser_addr.sin_addr.s_addr = htonl(INADDR_ANY);
lfd = socket(AF_INET, SOCK_STREAM, 0);
printf("lfd=%d",lfd);
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, (void*)&opt, sizeof(opt));
bind(lfd, (struct sockaddr*)&ser_addr,sizeof(ser_addr));
listen(lfd, 128);
struct pollfd fds[MAXFD];
int i;
int nfds;
nfds = 1;
int rr;
char buf[BUFSIZE];
for(i = 1; i < MAXFD; i++)
{
fds[i].fd = -1;
}
fds[0].fd = lfd;
fds[0].events = POLLIN;
int ret;
while(1)
{
ret = poll(fds, nfds, -1);
if(ret < 0)
{
perror("poll error");
exit(1);
}
else if(fds[0].revents & POLLIN)
{
cfd = accept(lfd,(struct sockaddr*)&cli_addr,&cli_addrlen);
char dst[64];
printf("client IP : %s PORT : %d successful connection\n",inet_ntop(AF_INET, &cli_addr.sin_addr.s_addr, dst, 64),ntohs(cli_addr.sin_port));
printf("客户端建立连接成功cfd=%d\n",cfd);
for(i = 1; i < MAXFD; i++)
{
if(fds[i].fd < 0)
{
fds[i].fd = cfd;
fds[i].events = POLLIN;
break;
}
}
if(i > nfds-1)
{
nfds = i+1;
}
}
for(i = 1; i < nfds; i++)
{
if(fds[i].fd == -1)
{
continue;
}
else if(fds[i].revents & POLLIN)
{
rr = read(fds[i].fd, buf, BUFSIZE);
if(rr < 0)
{
perror("read error");
exit(1);
}
else if(rr == 0)
{
printf("客户端断开连接\n");
close(fds[i].fd);
fds[i].fd = -1;
}
else
{
write(STDOUT_FILENO, buf, rr);
write(fds[i].fd, buf, rr);
}
if(--ret == 0)
{
break;
}
}
}
}
return 0;
}
五、epoll实现多路复用的方式
1、epoll 解决 select/poll 问题的第一点
epoll 在内核中使用红黑树来跟踪所有待检测的文件描述字。红黑树是一种高效的数据结构,其增删改操作的时间复杂度通常为 O(logn)。这与 select/poll 有显著不同,select/poll 内核中没有类似的高效数据结构来保存待检测的 socket 。每次 select/poll 操作时,都需要将整个 socket 集合传入内核,这会导致大量的数据拷贝和内存分配。比如说,在一个大型的网络直播平台服务器中,如果使用 select/poll ,每次都要把成千上万的连接信息完整地传递给内核,这会消耗大量的时间和系统资源。而 epoll 只需将新加入或有变化的单个 socket 传入内核中的红黑树,大大减少了数据传输和内存消耗。
2、epoll 解决 select/poll 问题的第二点
epoll 采用事件驱动的机制,内核维护了一个链表来记录就绪事件。当某个 socket 有事件发生时,通过回调函数将其加入到就绪事件列表中。当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,无需像 select/poll 那样轮询扫描整个 socket 集合。以一个在线多人游戏服务器为例,当有大量玩家同时进行操作时,epoll 能够快速准确地获取到有事件发生的连接,而 select/poll 则需要逐个检查所有连接,效率低下。
3、epoll 的优势和能力
epoll 的方式在监听的 Socket 数量增多时,效率不会大幅降低,能够同时监听的 Socket 数目上限为系统定义的进程打开的最大文件描述符个数。这使得 epoll 成为解决 C10K 问题(处理 10000 个并发连接)的有力工具。比如在一个繁忙的电商网站的服务器端,面对海量的并发请求,epoll 能够轻松应对,保证服务器的高效运行和快速响应。
4、关于 epoll 共享内存的错误观点
网上有一些错误的观点认为 epoll_wait 返回时,对于就绪的事件,epoll 使用的是共享内存的方式,避免了内存拷贝消耗。但实际上,从 epoll 内核源码中可以看到,在 epoll_wait 实现的内核代码中调用了 __put_user 函数,这表明是将数据从内核拷贝到用户空间,并非使用共享内存。
5、epoll服务端代码
cpp
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>
#include <sys/epoll.h>
#include <errno.h>
//宏,定义的是服务器的IP地址
#define SERIP "192.168.117.129"
//宏,定义的是服务器的端口号
#define SERPORT 8000
int main(int argc, char* argv[])
{
//创建一个TCP流式套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
//给监听套接字绑定地址结构
struct sockaddr_in seraddr, cliaddr;
seraddr.sin_family = AF_INET;
seraddr.sin_port = htons(SERPORT);
inet_pton(AF_INET, SERIP, &seraddr.sin_addr.s_addr);
int ret = bind(lfd, (struct sockaddr*)&seraddr, sizeof(seraddr));
if(ret < 0)
{
perror("bind error");
exit(1);
}
//设置监听
listen(lfd, 64);
//epoll
struct epoll_event events[1025], event;
char buf[5];
//epfd表示创建的epoll对象的文件描述符
int epfd = epoll_create(256);
//将lfd挂在到epoll对象上并注册监听事件
event.events = EPOLLIN;//读
event.data.fd = lfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &event);//将监听文件描述符挂载到(epoll实例中)红黑树
if(ret < 0)
{
perror("48 ctl error");
exit(1);
}
while(1)
{
int eret = epoll_wait(epfd, events, 1025, -1);//触发文件描述符个数
if(eret < 0)
{
perror("epoll error");
exit(1);
}
if(eret > 0)
{
int i;
for(i = 0; i < eret; i++)
{
if(events[i].events & EPOLLIN)
{
if(events[i].data.fd == lfd)
{
socklen_t addrlen = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &addrlen);
printf("客户端建立连接成功cfd = %d\n", cfd);
if(cfd<0)
{
perror("accept error");
exit(1);
}
else{
event.events = EPOLLIN ;
event.data.fd = cfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &event);//cfd也挂载到(epoll实例中)
if(ret < 0)
{
perror("84");
exit(1);
}
}
}
else
{
int rr =read(events[i].data.fd, buf, sizeof(buf));
if(rr < 0)
{
perror("read error");
exit(1);
}
else if(rr == 0)
{
printf("客户端断开连接\n");
epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
close(events[i].data.fd);
}
else if(rr > 0)
{
write(STDOUT_FILENO, buf, rr);
write(events[i].data.fd, buf, rr);
}
}
}
}
}
}
return 0;
}
6、epoll 的边缘触发(ET)和水平触发(LT)模式
边缘触发模式 :
在边缘触发模式下,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次。这就好比一个只响一次的闹钟,不管你是否响应,它都不再重复提醒。这要求我们的程序必须一次性将内核缓冲区的数据读取完。
例如,在一个实时金融交易系统中,新的交易数据到达时触发边缘模式,如果程序没有一次性读取完所有数据,可能会导致部分交易信息的遗漏,从而影响交易决策。
水平触发模式 :
水平触发模式则不同,只要被监控的 Socket 上有可读事件发生,服务器端就会不断地从 epoll_wait 中苏醒,直到内核缓冲区的数据被 read 函数读完。它就像一个坚持不懈的提醒者,直到任务完成为止。
比如在一个文件上传的场景中,如果采用水平触发,服务器会持续通知有数据可读,直到数据全部上传完成。
边缘触发与水平触发的举例 :
用快递箱的例子来理解,边缘触发就像是快递箱只通知一次,而水平触发则是不断通知直到快递被取出。这形象地展示了两种模式在通知方式上的本质区别。
两种触发模式的特点 :
水平触发意味着只要满足事件条件,比如内核中有数据需要读,就持续传递事件给用户。而边缘触发只有在第一次满足条件时触发,后续不再传递相同事件。
比如在一个在线聊天应用中,水平触发会不断提示有新消息,而边缘触发则在新消息首次到达时通知一次。
边缘触发与非阻塞 I/O 的搭配 :
在边缘触发模式中,由于 I/O 事件发生时只通知一次,且不确定能读写的数据量,所以收到通知后应尽可能地读写数据。如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数处,导致程序无法继续执行。因此,边缘触发模式一般与非阻塞 I/O 搭配使用,直到系统调用返回特定错误。假设在一个高并发的网络爬虫程序中,使用边缘触发和非阻塞 I/O 可以快速处理大量的网页数据请求,避免因阻塞而影响效率。
触发模式的效率比较 :
一般来说,边缘触发的效率高于水平触发,因为它能减少 epoll_wait 的系统调用次数。系统调用伴随着上下文切换的开销,减少调用次数有助于提高性能。在一个大型的数据库服务器中,这种效率的提升对于处理大量并发查询请求至关重要。
select/poll 与 epoll 的触发模式 :
select/poll 只有水平触发模式,而 epoll 默认是水平触发,但可以根据应用场景设置为边缘触发模式。这为开发者提供了更多的灵活性来优化程序性能。
以一个视频流服务器为例,如果需要高效处理大量并发连接,可以根据具体需求选择合适的触发模式。
I/O 多路复用与非阻塞 I/O 的搭配 :
Linux 手册中关于 select 的说明指出,多路复用 API 返回的事件不一定保证可读写,如果使用阻塞 I/O ,在调用 read/write 时可能导致程序阻塞。因此,为了应对这种特殊情况,最好搭配非阻塞 I/O 。例如,在一个分布式文件系统中,使用多路复用结合非阻塞 I/O 可以更好地处理大量的文件读写请求,提高系统的稳定性和响应性。
六、总结
1、基础的 TCP Socket 编程与阻塞 I/O 模型
最基础的 TCP 的 Socket 编程采用的是阻塞 I/O 模型。在这种模型下,当进行读写操作时,如果数据未准备好,程序会被阻塞等待,直到数据准备好为止。这意味着在同一时间,一个 Socket 连接只能进行一个操作,基本上只能实现一对一的通信。例如,想象一个简单的在线客服系统,使用阻塞 I/O 模型时,如果一个客服正在处理一个客户的请求并且被阻塞等待数据,那么在此期间无法响应其他客户的请求。
2、多进程/线程模型的局限性:
为了服务更多客户端,传统的方式是使用多进程/线程模型。每来一个客户端连接,就为其分配一个进程或线程来处理后续的读写操作。这种方式在处理少量客户端(如 100 个)时可能还可行,但当客户端数量大幅增加到 10000 个时,就会出现诸多问题。大量的进程/线程调度会消耗大量的 CPU 资源,上下文切换会带来额外的开销,而且每个进程/线程占用的内存也会累积起来,成为系统的沉重负担。比如在一个大型的网络游戏服务器中,如果为每个玩家都分配一个线程,当玩家数量激增时,服务器的性能会急剧下降,可能导致游戏卡顿甚至崩溃。
3、I/O 多路复用的出现:
为了解决多进程/线程模型的问题,I/O 多路复用应运而生。它允许在一个进程里处理多个文件的 I/O 操作,极大地提高了资源利用率和系统的并发处理能力。在 Linux 系统中,提供了 select、poll 和 epoll 这三种 I/O 多路复用的 API 。
4、select 和 poll 的工作原理与缺陷:
select 和 poll 在工作时,需要先将关注的 Socket 集合从用户态拷贝到内核态。内核检测事件时,需要遍历这个集合来找到有事件的 Socket 并设置其状态,然后再把整个集合拷贝回用户态。用户态程序同样需要遍历集合来找到可读/可写的 Socket 进行处理。当客户端数量众多,即 Socket 集合很大时,这种频繁的遍历和拷贝操作会带来极大的开销。假设我们有一个大型的电商网站服务器,在高峰期可能有成千上万的客户端连接,如果使用 select 或 poll ,这些遍历和拷贝操作会严重影响服务器的响应速度和处理能力。
5、epoll 解决问题的方式:
epoll 在内核中使用红黑树来管理待检测的 Socket ,红黑树的高效性使得增删改操作的时间复杂度为 O(logn) ,避免了像 select/poll 那样每次操作都传入整个 Socket 集合,从而减少了数据拷贝和内存分配。同时,epoll 采用事件驱动机制,内核通过维护一个链表来记录就绪事件,只将有事件的 Socket 集合传递给应用程序,无需像 select/poll 那样轮询整个集合,大大提高了检测效率。例如,在一个高并发的金融交易系统中,epoll 能够快速准确地处理大量的交易请求,保证系统的稳定和高效运行。
6、epoll 的触发方式优势:
epoll 支持边缘触发和水平触发两种方式,而 select/poll 只支持水平触发。一般来说,边缘触发的方式效率更高,因为它能减少不必要的系统调用和事件检测。比如在一个实时视频流服务器中,边缘触发可以更及时地处理数据,提供更流畅的视频播放体验。