一 引入
前段时间在看业务这边系统耗时,想到了之前该系统框架部分有提过关于一个event扩展。提到了在高并发场景下优化方案,因此比较好奇它是如何做到的?
了解了一下优化方案中背后的核心,顺着提到了关于IO多路复用的模型:select与epoll。恰好在之前有看了一下框架下的常驻执行流程,现在结合起来看更加清晰了许多。
二 基本概念
2.1 关于文件描述符
在linux中,一切接可以看成是文件。而操作文件的时候,系统不可能每次都根据你的文件名称去文件系统里去查查一次具体是哪个位置,这样效率不高。于是linux规定,每个文件对应一个索引。这样当系统要去定位到文件时,根据索引就可以快速定位了。
文件描述符就是内核为了提高系统管理这些被打开的文件所创建的索引,是一个非负整数。
通常我们会看到这样子的命令,比如:
./bootstrap > run.log 2>&1
这里的2,1其实就是系统定义好的文件描述符。系统预定义的有以下3个:0、1、2
fd | 文件指针 | 文件 |
0 | stdin | |
1 | stdout | |
2 | strerr | |
… |
2.2 关于异步与同步
这点在用前端里面的请求来举例,用户在网页上点击操作,去更新信息场景。一种是点击后,等待服务端操作返回并拿到结果;另一种是点击后,立刻告知用户发送成功,但是执行结果不会立刻告知你,比如这种通常利用ajax中,异步请求的方式,通过回调的方式,在服务端返回后再触发原先注册的行为。
2.3 关于阻塞与非阻塞
阻塞:比如redis里面有一个发布订阅的模式,在订阅端在没有接收到数据时,进程会一直挂起,直到有数据。
看下面这个例子,简单写了一个服务端,阻塞模式
<?php
declare(strict_types=1);
$host = '0.0.0.0';
$port = 8787;
// 声明一个服务端
$server = stream_socket_server("tcp://$host:$port", $errNo, $errMsg);
// 开始循环
while (true) {
printf("准备接受连接\n");
// 阻塞模式下,当没有客户端来连接时,服务端一直阻塞在这里,直到有新的连接进来
$client = stream_socket_accept($server, -1);
printf("接受连接\n");
// 同样,此处的读数据也是阻塞模式
while ($msg = fread($client, 65535)) {
printf("%s\n", $msg);
}
fclose($client);
}
非阻塞,比如一个进程在监听一个文件描述符的数据是否就绪时,不阻塞,直接向内核拿结果,如果内核没有就绪就直接返回error,如果就绪就返回数据。这样,当一直没有就绪的时候,进程就一直去不断地询问内核。
<?php
declare(strict_types=1);
$host = '0.0.0.0';
$port = 8787;
$server = stream_socket_server("tcp://$host:$port", $errNo, $errMsg);
printf("create server\n");
// 相比上文的阻塞模式,这里声明了非阻塞模式
stream_set_blocking($server, false);
printf("set block\n");
while (true) {
try {
printf("waiting for a new accept\n");
// 同时这里声明超时时间为0,即不等待直接获取accept结果
$client = @stream_socket_accept($server, 0);
if ($client === false) {
throw new Exception('operation time out');
}
printf("accept socket\n");
stream_set_blocking($client, true);
while ($msg = fread($client, 65535)) {
printf($msg);
}
fclose($client);
fclose($server);
break;
} catch (Throwable $e) {
printf("error:%s\n",$e->getMessage());
}
}
// 以上代码执行时,会不断地循环输出以下信息
// waiting for a new accept
// error:operation time out
网络IO分两个阶段
- 第一个,准备阶段,判断是否能够操作,即等待数据是否可用,是在内核中完成的
- 第二个,操作阶段,即执行实际的IO调用,数据从内核缓冲区拷贝到用户进程缓冲区。
比如一个read操作,第一阶段,等待数据准备,数据是否拷贝到内核缓冲区;第二阶段,将数据从内核拷贝到用户进程空间
总结起来就是,阻塞与非阻塞,它们是进程访问的数据如果未就绪(IO操作第一阶段的完成方式),进程的表现。
那么如果需要保证服务端能够容纳多个客户端的请求,一种方案就是在多个一个线程,用来保证可以接受新的客户端连接。这样以来,就得用线程数来支持客户端数,非常浪费资源。另一种方式,就是一个线程,去实现连接多个客户端请求。这里就利用到了IO多路复用。
三 多路复用模型
3.1 select模型
在linux里面,程序的运行分内核态与用户态。为了保护系统自身的运行安全,程序去获得数据时,都需要经过内核的调度才能获取到。
下图画了select模型下涉及的结构。
// 以下是一段简写的调用select函数的服务端,其中部分声明被省略
// Create a socket
serverSocket = socket(AF_INET, SOCK_STREAM, 0)
// Bind the socket
bind(serverSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr)
listen(serverSocket, 3)
// 设置文件描述符集合
FD_SET readfds;
for (i=0; i<3; i++) {
// 将监听的文件描述符加入到数组中
fd[i] = accept(serverSocket, (struct sockaddr*)&client, &addr_len);
// 记录最大的文件描述符
if (fd[i] > max) {
max = fd[i];
}
}
// Main server loop
while (1) {
// 重置文件描述符集合,将bitmap置为0
FD_ZERO(&readfds);
for (i=0;i <3; i++) {
// 将要监听的文件描述符所在的bitmap置为1
FD_SET(fd[i], &readfds);
}
// 参数1为最大的文件描述符+1
// 参数2为要监听的文件描述符集合,参数3为写文件描述符集合,参数4为异常文件描述符集合,
// 参数5为超时时间,这里设置为null,表示不超时,永远等待直到有数据进来
// 返回值为活跃的文件描述符的个数
activity = select(max + 1, &readfds, NULL, NULL, NULL);
// 执行select时,将用户态的文件描述符集合拷贝到内核态,内核态根据文件描述符集合中的文件描述符去设置监听
// 当有数据进来时,内核态会将该socket对应的文件描述符的bitmap的值置为1,表示有数据进来
// 然后socket里关联的等待队列中的进程就会被唤醒
// 之后select返回,将内核态的文件描述符集合拷贝到用户态,用户态根据文件描述符集合中的文件描述符去读取数据
for (i=0; i<3; i++) {
// 挨个去判断哪个文件描述符的bitmap为1,表示有数据进来
if (FD_ISSET(fd[i], &readfds)) {
// read from fd[i]
...
}
}
}
整体流程概述:
- 将当前进程监听的所有文件描述符拷贝到内核态
- 内核遍历监听文件描述符哪些有数据准备好了
- 内核将有准备好数据的文件描述符重新拷贝回用户态
- 用户态遍历文件描述符哪些已经就绪,进行下一步处理
时间复杂度:O(n)
限制:
- 文件描述符数量受限,1024
- 每次需要将用户态的描述符bitmap拷贝到内核态,同时又将bitmap拷贝到用户态
- 每次拷贝的bitmap是全量数据
3.2 epoll模型
// Create a socket
serverSocket = socket(AF_INET, SOCK_STREAM, 0);
// Bind the socket
bind(serverSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr);
listen(serverSocket, 3);
// 关键函数1,epoll_create,创建一个eventepoll
// 这里的参数在后面linux的版本迭代中已经没有意义,为了向前兼容只需要传大于0的数就可以了
// 得到一个epfd,会在接下来的epoll调用中使用
epfd = epoll_create(3);
for (i=0; i<3; i++) {
// 声明一个epoll_event结构体
// 关于结构定义如下
// typedef union epoll_data {
// void *ptr;
// int fd;
// uint32_t u32;
// uint64_t u64;
// } epoll_data_t;
//
// struct epoll_event {
// uint32_t events; /* Epoll events */
// epoll_data_t data; /* User data variable */
// };
struct epoll_event event;
// 记录要监听的文件描述符
event.data.fd = accept(serverSocket, (struct sockaddr *)&clientAddr, &addr_size);
// 设置要监听的事件类型,这里是可读事件
event.events = EPOLLIN;
// 关键函数2,epoll_ctl
// 声明EPOLL_CTL_ADD,将上面的结构体注册到epoll中,会得到一个epitem结构体
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
}
struct epoll_event evt[3];
while (1) {
// 等待事件发生,-1表示一直阻塞知道有事件就绪
// 3表示最多就绪好的事件,比如如果一下子就5个就绪了,那么只会返回3个
// 关键函数3,epoll_wait,类似于select
int ret = epoll_wait(epfd, evt, 3, -1);
// 拿到已就绪的结果,挨个处理,每个都是有效的
for (i=0; i<ret; i++) {
if (evt[i].events & EPOLLIN) {
// read data
}
}
}
整体流程概述:
- 通过epoll_create,创建一个eventpoll实体,里面包含的关键属性有一个红黑树、已就绪的双端队列等等
- 将需要监听的文件描述符通过epoll_ctl通知到内核,内部会生成一个epitem的对象,其中包含的关键属性有在红黑树中的节点、关联的eventpoll的指针、文件描述符、监听的event、就绪队列所在节点等等
- 在创建epitem的同时,还会在这个socket上注册一个回调函数,用于数据就绪之后被回调
- 调用epoll_wait,如果此时已就绪的双端队列已有准备好的数据,则直接返回。如果队列为空或者阻塞时间未超时,则阻塞该进程。直到客户端的数据,到达网卡,并通过DMA拷贝技术将数据拷贝到环形缓冲区,并通知到CPU,此时CPU发生软中断,此时利用socket中的等待队列里的回调函数,将该已就绪的文件描述符组织成双端队列,然后将双端队列拷贝回用户态
- 用户态进程阻塞被解除,拿到了已就绪的双端队列,然后循环处理已经准备好的数据
时间复杂度:O(1)
3.3 epoll触发模式
水平触发:LT,即只要监听的文件描述符里面有数据还没有被取走,那么就一直就绪,就一直返回给用户态。
边缘触发:ET,即只有在有新的数据进来的时候才会通知,假设监听的文件描述符里的数据一次性没有取走,那么下次就不会再通知你了,除非有新的数据再进来。nginx就是利用这种模式加非阻塞读的方式实现的高效性能
3.4 对比
相比select:
受文件描述符集合数量限制,epoll采用了红黑树加双端链表的形式,不存在监听数限制;
无需将全部监听的文件描述符全部拷贝,只拷贝关键的信息;
四 压测
从前面两者实现逻辑来看,无论是数据结构还是能够监听的文件数量,epoll都比select高效,那在实际的压测环境中,两者能够拉开距离吗?请看压测实验:https://www.workerman.net/q/11745
我选择了开不开event的对比方式来做实验,发现两者的在并发数1500的情况下表现一致,为什么会出现这种问题呢?
结合上面对select与epoll的内部实现,我们还要考虑的实际的压测场景下对于不同模型下的影响。在高并发模式下,其实每个连接建立后,客户端都是源源不断的发起数据请求,这样一来,即使是select模式,每次将监听的文件描述符全部拷贝回用户态,进程在遍历的时候,每个文件描述符都是就绪的,并没有浪费时间。如此一来所以两者提现出来的性能是一致的。
当然在并发数5000的时候,自然是不用说的了,单个进程select接受不了这么高的并发,所以压测失败。
五 写在最后
这次是从压测结果出发,然后再去了解背后涉及的两个模型,在看各种资料的时候,不经意间又探知到了未知领域,还有很多需要去学习。
学习资料(有些资料存在错误,需要结合起来看)