Node.js 是如何跑起来的( 六 )

上面是使用 select 函数实现的 I/O 多路复用,实际在 Libuv 采用的是 epoll 函数,epoll 函数是为了解决 select 的以下缺点而诞生的:

  • 监听的 I/O 最大连接数量有限,Libux 系统下一般为 1024
  • 一方面,监听的 fd 列表需要从用户空间传递到内核空间进行 socket 列表的监听;另一方面,当数据就绪后,又需要从内核空间复制到用户空间,随着监听的 fd 数量增长,效率也会下降 。
  • 此外,select 函数返回后,每次都需要遍历一遍监听的 fd 列表,找到数据就绪的 fd 。
图片来源:https://www.51cto.com/article/693213.html
epoll 的优势在于:
  • 基于事件驱动,每次只返回就绪的 fd,避免所有 fd 的遍历操作
  • epoll 的 fd 数量上限是操作系统最大的文件句柄数目,一般与内存相关
  • 底层使用红黑树管理监听的 fd 列表,红黑树在增删、查询等操作上时间复杂度均是 logN,效率较高
int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);// 设置 socket 为非阻塞 I/Oint flags = fcntl(serv_sock, F_GETFL, 0);fcntl(serv_sock, F_SETFL, flags | O_NONBLOCK);// IP 和端口配置 ...// ...// 绑定 IP 和端口bind(serv_sock, ...);// 监听来自监听型套接字的请求listen(serv_sock, ...);// 创建 epoll 对象int MAX_EVENTS = 5; // 告诉内核可能需要监听的 fd 数量,如果使用时大于该数,则内核会申请动态申请工多空间int epoll_fd = epoll_create(MAX_EVENTS);if (epoll_fd == -1) {printf("epoll_create error!n");return -1;}// 注册 serv_sock 所监听的事件struct epoll_event ev;struct epoll_event events[MAX_EVENTS];ev.data.fd = serv_sock; // 设置该事件的 fd 为 serv_sockev.events = EPOLLIN; // 设置监听 serv_sock 的可读事件// 添加 serv_sock 到 epoll 的可读事件监听队列中int ret = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, serv_sock, &ev);if (ret == -1) {printf("epoll_ctl error!n");return -1;}int connfd = 0; // 与客户端连接成功后的通信型 fdwhile (1) {// int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);// 等待 epoll_fd 中的事件,如果 serv_sock 有可读事件发生,则函数返回就绪后的 fd 数量// 最后一个 timeout 参数可用来控制 epoll_wait 的等待事件发生的时间,-1 为阻塞等待,0 为非阻塞立即返回int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);for (int i = 0; i < nfds; i++) {// 客户端发起请求if (events[i].data.fd == serv_sock) {connfd = accept(serv_sock, ...);if (connfd == -1) {printf("accept error!n");}ev.data.fd = connfd; // 设置该事件的 fd 为当前的 connfdev.events = EPOLLIN; // 设置当前的 connfd 的可读事件// 添加当前 connfd 到 epoll 的可读事件监听队列中if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, connfd, &ev) == -1) {printf("epoll_ctl add error!n");return -1;}} else {// 某个来自客户端的请求的数据已就绪int ret = recvfrom(i, ...);if (ret == -1 && errno == EAGAIN) {fprintf(stderr, "no data readyn");continue;} else if (ret == -1) {perror("read failed");}// 处理数据handle(data);}}}以上就是基于 epoll 机制的事件驱动型的 I/O 多路复用模型,服务器通过注册文件描述符及其对应监听的事件到 epoll(epoll_ctl),epoll 开始阻塞监听事件直到有某个 fd 的监听事件触发(epoll_wait),然后就遍历就绪事件,根据 fd 类型的不同执行不同的任务 。
服务器架构?单进程单线程·串行模型?int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);// IP 和端口配置 ...// ...// 绑定 IP 和端口bind(serv_sock, ...);// 监听来自监听型套接字的请求listen(serv_sock, ...);int clnt_sock;while (1) {// 创建新的通信型套接字用于接收来自客户端的请求,此时会阻塞程序执行,直到有请求到来clnt_sock = accept(serv_sock, ...);// 接收客户端的数据,同步阻塞 I/O,等待数据就绪recvfrom(clnt_sock, ...);// 处理数据handle(data);}单进程单线程·串行处理请求是最简单的服务器架构,先从经过三次握手,然后从连接队列中获取客户端连接节点(accept 返回的套接字),然后从客户端的套接字获取数据进行处理,接下来再进行下个连接节点处理 。
在并发连接数较大的情况下,并且采用的是阻塞式 I/O 模型,那么处理客户端连接的效率就会非常低 。
?多进程/多线程?单进程串行处理请求因为阻塞 I/O 导致连接队列中的节点被阻塞导致处理效率低下,通过把请求分给多个进程处理从而提升效率,人多力量大 。在多进程/多线程架构下,如果一个请求发送阻塞 I/O,那么操作系统会挂起该进程,接着调度其他进程,实现并发处理能力的提高 。


推荐阅读