跳至主要內容

LincZero大约 21 分钟

参考:【B站】网络编程系列(select、poll、epoll、Reactor模型、Proactor模型)open in new window

前置知识

整个网络演变过程

  1. 阻塞IO (BIO)
  2. 非阻塞IO (NIO)
  3. IO多路复用第一版 (select/poll)
  4. IO多路复用第二版 (epoll)
  5. 信号驱动IO
  6. 异步IO (AIO)

问题:编写一个server的步骤

// 1. 创建serverFd,这里的服务套接字也叫欢迎套接字
serverFd=Socket(opt);
// 2. 将fd和指定的地址(ip+port)进行绑定
Bind(serverFd, address);
// 3. 监听前面绑定时指定的地址
Listen(serverFd);
// 4. 进入无限循环来等待客户端连接请求。这里的客户套接字clientFd也叫连接套接字ConnectFd
clientFd=Accept(serverFd);

server是怎么处理建立连接后的client请求的

// 从客户端里读取传输进来的数据,并将数据存放到buf中
n = read(clientFd, buf, size);
// 往客户端写出数据n个字节的数据,写出的数据存放在buf中
write(clientFd, buf, n);

server和client完整交互过程(这个图是《Unix网络编程》里的,被视频和博客广泛引用)

image-20230522194337940

网络演变的本质是什么?下表从上往下演变

  • < 内核版本, 特征, 网络连接数量
  • 0.96, 支持网络, 十
  • , 阻塞、非阻塞, 百
  • <2.1, select, 千
  • 2.1, poll, 万
  • 2.5, epoll, 十万
  • , , 百万

一、阻塞IO

  • 优点
    • 可以实现client和server端通信
    • 实现简单,通常一个client连接分配一个线程进行处理
  • 缺点
    • 能支持的并发client连接数较少
    • 在于一台server能分配的线程是有限的
    • 大量线程会造成上下文切换过多而影响性能

image-20230522195219787

二、非阻塞IO

改进,引入非阻塞IO

  • 核心矛盾
    • 之所以一个client连接分配一个线程是因为处理客户段的读写是阻塞式的,为避免该阻塞影响后续接收新的client,所以将阻塞逻辑叫由单独线程处理
  • 直观改进思路
    • 阻塞IO(BIO, Blocking IO) --内核改进支持--> 非阻塞IO(NIO, NonBlocking IO)

区别:阻塞IO和非阻塞IO,BIO & NIO

  • 区别
    • 主要区别在于:内核中数据尚未就绪时,如何处理。
      • 对于非阻塞IO,则直接返回给用户态EWOULDBLOCK错误;
      • 而阻塞IO则一直处于阻塞状态,直到数据就绪并从内核态拷贝到用户态后才返回

image-20230522195750296

区别

image-20230522200111693

如何设置非阻塞IO

  • 方法1

    • 通过 socket() 方法中的type参数来指定 SOCK_NOBLOCK 即可设置该socket为非阻塞方式

      int socket(int domain, int type, int protocol);
      
  • 方法2(更常见和灵活)

    • 通过 fcntl() 方法中args参数设置为 O_NONBLOCK 即可设置该socket为非阻塞方式

      int fcntl(int fd,m int cmd, .../*arg*/);
      

非阻塞IO特点

  • 优点
    • 将socket设置为非阻塞后,在读取时如果数据未就绪就直接返回。得益于非阻塞的特性可以通过一个线程管理多个client连接
  • 缺点
    • 需要不断的轮询询问内核,数据是否已就续,涉及很多无效的、太频繁的系统调用(system call)

三、IO多路复用第一版(select&poll)介绍

改进,询问变通知

如何改进非阻塞IO?

  • 核心矛盾
    • 涉及很多无用的、频繁的系统调用的原因是:非阻塞socket在read时并不知道什么时候数据会准备好了,所以需要不断主动询问
  • 直观改进思路
    • 非阻塞IO (NIO, 主动询问、单个) --内核改进支持--> IO多路复用第一版 (等待通知、批量)
  • IO多路复用第一版
    • select、poll

image-20230522201042457

区别

image-20230522201226022

思考:IO多路复用到底复用了什么

个人观点:

  • IO多路复用主要复用的是系统调用。 从原先阻塞情况下的多个client需要各自多次发送recvfrom系统调用去不断询问内核数据是否就绪;变成了现在通过一次系统调用select/poll由内核主动通知用户哪些client数据已就绪(read、write、accept等事件)。大大减少了无效的系统调用次数。

select核心接口及原理分析

select()定义 (cpp libevent库那个和这个很类似)

#include <sys/select.h>
#include <sys/time.h>

int select(
    int maxfdp1, 
    fd_set *readset,  // 读事件
    fd_set *writeset,  // 写事件
    fd_set *exceptset,  // 异常事件
    const struct timeval *timeout // 超时事件
);

// 各参数含义
{
    // 表示被select管理的描述符个数。值为最大描述符+1。不是描述符最大值
    maxfdp1

    // 表示一组描述符集合,select中是用一个个位数来实现的,一个描述符占一位
    fd_set
    // 可读事件集合、可写事件集合、异常事件集合。这三者都可以填null
    readset、writeset、exptset

    // 超过时间有三种含义,可以通过值判断:永远等待(null)、正常超时(>0)、立即返回(0)
    timeout
}

实际举例

{1, 4, 5}
{2, 7}
{1, 4}

扩展:select核心接口

其他接口定义:

void FD_ZERO(fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_CLR(int fd, fd_set *fdset);
void FD_ISSET(int fd, fd_set *fdset);

// 例如:
fd_set rset;
FD_ZERO(&rset);
FD_SET(1, &rset);
FD_SET(4, &rset);
FD_SET(5, &rset);

fd_set实现(原文档)

What we are describing, an array of integers using one bit per descriptor is just one possible way to implement select. Nevertheless, it is common to refer to the individual descriptors within a descriptor set as bits, as in "turn on the bit for the listening descriptor in the read set. " We will see in Section 6.10 that the poll Function uses a completely different representation: avariable-length array of structures with one structure per descriptor.

我们所描述的是一个整数数组,每个描述符使用一个比特,这只是实现“select”的一种可能方式。然而,通常将描述符集中的单个描述符称为位,如“打开读取集中侦听描述符的位”。

我们将在第6.10节看到poll函数使用完全不同的表示:每个描述符一个结构的可变长度结构数组。

select为什么需要maxfdp1?

和poll核心接口及原理分析

poll()定义

#include <poll.h>

/**
 * @param fdarray
 *     传入pollfd数组的首地址,该数组中的每一个元素为一个pollfd结构体对象,关联一个管理的描述符fd
 *     pollfd 结构:
 *     struct pollfd {
 *         int   fd;        // 检查描述符
 *         short events;    // 要监听的事件
 *         short revents;   // 事件的回调
 *     }
 * @param nfds
 *     fdarray数组的长度,表述管理的描述符个数。主要原因在于前面的fdarray是一个可变长度的数组,因为需要指定长度
 * @param timeout 有三种取值(和select是一样的)
 *     - 无限等待(INFTIM,即<0)
 *     - 立即返回不阻塞(0)
 *     - 等待制定的超时时间(timeout,即>0)
 */
int poll(
    struct pollfd *fdarray,		// 
    usinged log nfds,			// (1、2参数构成一个动态数组)
    int timeout					// 超时时间
);

扩展:poll事件定义

四类处理输入事件、三类处理输出事件、三类处理错误事件

poll识别三类事件:

  • 普通 (normal)
  • 优先级带 (priority band)
  • 高优先级 (priority)

select & poll 总结

有什么区别

  • < 维度, select, poll
  • 实现
    • select底层使用位数组来实现,一个描述符对应一位
    • poll底层通过pollfd结构体来实现,管理的描述符通过pollfd数组来组织,一个描述符对应一个pollfd对象
  • 用法不同
    • select默认大小是FD_SETSIZE(1024),修改的话需要修改配置参数同时重新编译内核实现
    • poll采用变长数组管理,因此理论上可以支持管理海量连接
  • 相同点
    • 两者都属于IO多路服用第一版的经典实现。 两者在调用时,都需要从用户态拷贝管理的全量描述符到内核态;返回时都从内核态拷贝全量的描述符到用户态;再由用户态遍历全量的描述符判断哪些描述符有就续事件
    • <<

思考:IO多路复用第一版有什么问题?

  • 优点
    • 充分利用了一次系统调用select()/poll()就可以实现多个client的事件(read、write、accept等)。大大降低了之前非阻塞IO时频繁无效的系统调用。 核心思路是:将主动询问内核 转变为 等待内核通知,提升了性能
  • 缺点
    • 每次select()/poll()都需要将注册管理的多个client从用户态拷贝到内核态。在管理百万连接时,由拷贝带来的资源开销较大,影响性能

IO多路复用第二版(epoll)

改进

怎么改进?

  • 核心矛盾
    • 从主动轮询 转变为 被动通知,确实提升了性能。但select()/poll()每次调用都需要拷贝管理的全量的fd到内核态,导致影响性能
  • 改进思路
    • 拷贝、模糊通知 --内核改进支持--> 不拷贝、明确通知

image-20230522225919010

epoll核心接口及两种模式介绍

epoll三大核心接口

  • epoll_create()
  • epoll_ctl()
  • epoll_wait()

epoll_create()

定义

#include <sys/epoll.h>

int epoll_create(int size);
    
// ---
#include <sys/epoll.h>
int epoll_create1(int flags);

备注:

  1. 从linux2.6.8以后,size参数已被忽略,但必须大于0
  2. epoll_create() 创建返回的epollfd指向内核中的一个epoll实例,同时该epollfd用来调用所有和epoll相关的接口(epoll_ctl()、epoll_wait())
  3. 当epollfd不再使用时,需要调用close()关闭。当所有指向epoll的文件描述符关闭后,内核会摧毁该epoll实例并释放和其关联的资源
  4. 成功返回时,返回大于0的epollfd。失败时返回-1,根据errno查看错误。

size说明

epoll_create1()

epoll_ctl()

定义

#include<sys/epoll.h>

/**
@param epfd 通过epoll_create()创建的epollfd
@param op
    - EPOLL_CTL_ADD,添加
    - EPOLL_CTL_MOD,更新
    - EPOLL_CTL_DEL,删除
@param fd 待监听的描述符fd
@param event 要监听的fd的事件(读、写、接受连接等),具体事件定义见下页表格
 */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

大白话理解:将 哪个客户端(fd)哪些事件(event) 交给 哪个epoll(epfd) 来管理(op:增删改)

epoll_wait()

定义

#include<sys/epoll.h>

/**
@param epfd 通过epoll_create()创建的epollfd
@param event 返回就续的事件列表,就绪的事件列表个数通过epoll_wait()的返回值来传递
@param maxevents 最多返回的events个数,该值用来告诉内核创建的events有多大
@param timeout 超时时间
    - -1表示无限期等待
    - 0表示立即返回
    - >0表示正常超时时间 timeout
@return cnt
    - 0表示超时时间范围内无就绪列表
    - >0表示返回就续列表的个数(后续通过循环遍历events[0]~events[cnt-1])
    - -1表示错误,通过errno来识别具体错误信息
 */
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll重要事件介绍

重要事件介绍

  • 事件宏定义, 具体含义
  • EPOLLIN, 表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
  • EPOLLOUT, 表示对应的文件描述符可以写
  • EPOLLPRI, 表示对应的文件描述符有紧急数据可读(这里应该表示有带外数据到来)
  • EPOLLERR, 表示对应的文件描述符发生错误
  • EPOLLHUP, 表示对应的文件描述符被挂断
  • EPOLLET, 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的
  • EPOLLONESHOT, 只监听一次事件,当监听完整次事件后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

epoll的 ET模式 & LT模式区别

区别

  • 维度, ET(edge-trigger)模式, LT(level-trigger)模式
  • 触发时机
    • 仅当监控的描述符有事件就绪时触发
    • 当监控的描述符有事件就绪或就绪事件未完全处理完时都会触发
  • 性能消耗
    • 相同场景下ET模式所涉及的系统调用次数较少
    • 相同场景下LT模式所设计的系统调用相对较多
  • 编程难度
    • 难度较高、数据完整性交由上层用户态保证
    • 难度较低、数据完整性交由内核来保证,epoll默认模式就是LT模式
  • 相同点
    • 都属于epoll内置的触发模式,都可以实现网络传输功能
    • <<

扩展:epoll内核实现

  • epoll_create()
    • 在内核分配一段空间,并初始化管理监听描述符的数据结构:红黑树就绪事件链表
  • epoll_ctl()
    • 暴露给上层用户的对底层红黑树的增删改接口:
      • EPOLL_CTL_ADD,添加
      • EPOLL_CTL_MOD,更新
      • EPOLL_CTL_DEL,删除
  • epoll_wait()
    • 可从就绪事件链表中获取就绪事件关联的描述符,然后填充到events中并返回上层用户
  • 就绪事件迁移
    • 当内核监听到有就绪事件中断时就会将就绪事件从红黑树迁移一份到就绪事件链表中

信号驱动IO和异步IO介绍

信号驱动IO

image-20230523230232925

异步IO

image-20230523230332462

思考 select/poll/epoll 是同步IO还是异步IO,怎么区分?

都是同步IO

处理IO方面都是同步IO

区分同步IO 异步IO

image-20230523230533174

第二阶段,前四个都会阻塞,前四个都是同步IO

扩展:惊群效应

  • linux惊群效应
    • 参考:https://blog.csdn.net/sinat_35297665/article/details/80569656 转自:https://blog.csdn.net/lyztyycode/article/details/78648798?locationNum=6&fps=1 详细的介绍什么是惊群,惊群在线程和进程中的具体表现,惊群的系统消耗和惊群的处理方法。

惊群效应是什么?

惊群效应也有人叫做雷鸣群体效应,不过叫什么,简言之: 惊群现象就是多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只可能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态。 这种现象和性能浪费就叫做惊群。

为了更好的理解何为惊群,举一个很简单的例子,当你往一群鸽子中间扔一粒谷子,所有的各自都被惊动前来抢夺这粒食物,但是最终注定只可能有一个鸽子满意的抢到食物,没有抢到的鸽子只好回去继续睡觉,等待下一粒谷子的到来。这里鸽子表示进程(线程),那粒谷子就是等待处理的事件。

看一下: WIKI的雷鸣群体效应的解释open in new window

惊群效应到底消耗了什么?

我想你应该也会有跟我一样的问题,那就是惊群效应到底消耗了什么?

  1. 系统对用户进程/线程频繁地做无效的调度,上下文切换系统性能大打折扣。
  2. 为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,进一步加大了系统开销。

是不是还是觉得不够深入,概念化?看下面:

  1. 上下文切换(context switch)过高会导致cpu像个搬运工,频繁地在寄存器和运行队列之间奔波, 更多的时间花在了进程(线程)切换,而不是在真正工作的进程(线程)上面。 直接的消耗包括cpu寄存器要保存和加载(例如程序计数器)、系统调度器的代码需要执行。 间接的消耗在于多核cache之间的共享数据。 看一下: wiki上下文切换open in new window
  2. 通过锁机制解决惊群效应是一种方法,在任意时刻只让一个进程(线程)处理等待的事件。但是锁机制也会造成cpu等资源的消耗和性能损耗。 目前一些常见的服务器软件有的是通过锁机制解决的,比如nginx(它的锁机制是默认开启的,可以关闭); 还有些认为惊群对系统性能影响不大,没有去处理,比如lighttpd。

惊群效应的庐山真面目

让我们从进程和线程两个方面来揭开惊群效应的庐山真面目:

accept() 惊群

扩展:c10k问题

  • c10k问题
    • 参考:https://evernote.blog.csdn.net/article/details/102780959

C10k问题简述

所谓c10k问题,指的是:服务器如何支持10k个并发连接,也就是concurrent 10000 connection(这也是c10k这个名字的由来)。

由于硬件成本的大幅度降低和硬件技术的进步,如果一台服务器能够同时服务更多的客户端,那么也就意味着服务每一个客户端的成本大幅度降低。从这个角度来看,c10k问题显得非常有意义。

一、C10K问题由来

互联网的基础是网络通信,早期的互联网可以说是一个小群体的集合。互联网还不够普及,用户也不多,一台服务器同时在线100个用户,在当时已经算是大型应用了,所以并不存在 C10K 的难题。互联网的爆发期是在www网站、浏览器出现后。最早的互联网称之为Web1.0,大部分的使用场景是下载一个HTML页面,用户在浏览器中查看网页上的信息,这个时期也不存在C10K问题。

Web2.0时代到来后,就不同了。一方面是,互联网普及率大大提高了,用户群体几何倍增长。另一方面是,互联网不再是单纯地浏览www网页,逐渐开始进行交互,而且应用程序的逻辑也变得更复杂。从简单的表单提交,到即时通信和在线实时互动,C10K的问题才体现出来了。因为每一个用户都必须与服务器保持连接,才能进行实时数据交互。诸如Facebook这样的网站,同一时间的并发TCP连接很可能已经过亿。

早期的腾讯QQ也同样面临C10K问题,只不过他们是用了UDP这种原始的包交换协议来实现的,绕开了这个难题,当然过程肯定是痛苦的。如果当时有epoll技术,他们肯定会用TCP。众所周之,后来的手机QQ、微信都采用TCP协议。

实际上,当时也有异步模式,如:select/poll模型。这些技术都有一定的缺点:selelct最大不能超过1024;poll没有限制,但每次收到数据时,需要遍历每一个连接,查看哪个连接有数据请求。

这时候问题就来了,最初的服务器都是基于进程/线程模型的,新到来一个TCP连接,就需要分配1个进程(或者线程)。进程又是操作系统最昂贵的资源,一台机器无法创建很多进程。如果是C10K,就要创建1万个进程,那么就单机而言,操作系统是无法承受的(往往出现效率低下、甚至完全瘫痪)。 如果是采用分布式系统,维持1亿用户在线需要10万台服务器,成本巨大,也只有Facebook、Google、Apple等巨头,才有财力购买如此多的服务器。

基于上述考虑,如何突破单机性能局限,是高性能网络编程所必须要直面的问题。这些局限和问题,最早被Dan Kegel 进行了归纳和总结,并首次系统地分析和提出了解决方案。后来,这种普遍的网络现象和技术局限,都被大家称为 C10K 问题。

C10K问题的本质

C10K问题,本质上是操作系统的问题。对于Web1.0/2.0时代的操作系统而言, 传统的同步阻塞I/O模型都是一样的,处理的方式都是requests per second,并发10K和100的区别关键在于CPU。

创建的进程、线程多了,数据拷贝频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞), 进程/线程上下文切换消耗大, 导致操作系统崩溃,这就是C10K问题的本质!

可见,解决C10K问题的关键就是:尽可能减少CPU等核心资源消耗,从而榨干单台服务器的性能,突破C10K问题所描述的瓶颈。

C10K问题的解决方案探讨

从网络编程技术的角度来说,主要思路为:

  1. 为每个连接分配一个独立的线程/进程。
  2. 同一个线程/进程同时处理多个连接**(IO多路复用)**。

为每个连接分配一个独立的线程/进程

同一个线程/进程同时处理多个连接(IO多路复用)

实现方式1:循环逐个处理各个连接,每个连接对应一个 socket

实现方式2:使用select方法

实现方式3:使用poll方法

实现方式4:使用epoll方法

实现方式5:使用libevent库

引申讨论C10M问题

随着技术的演进,epoll已经可以较好地处理 C10K 问题。但是,如果要进一步的扩展,例如支持10M 规模的并发连接,原有的技术就无能为力了。那么,新的瓶颈在哪里呢?

从前面的演化过程中,我们可以看到,根本的思路是:要高效地去除阻塞,让CPU更多地处理核心任务。所以,就千万级并发而言,内核不是解决方案,而是问题所在!

...

...

...

这就是协程的本质。协程是异步非阻塞的另外一种展现形式。Golang、Erlang、Lua协程都是这个模型。

同步阻塞

大家看完协程,是否感觉到:实际上,协程和同步阻塞是一样的。答案是正确的。所以,协程也叫做用户态进程/用户态线程。区别就在于:进程/线程是操作系统充当了EventLoop调度,而协程是应用程序自己用Epoll进行调度。

协程的优点是:它比系统线程开销小。其缺点是:如果其中一个协程中有密集计算,其他的协程就不运行了。

操作系统进程的优点是:无论代码怎么写,所有进程都可以并发运行。其缺点是:开销大。

...

...

...

实际上,同步阻塞程序的性能并不差,它的效率很高,不会浪费资源。当进程发生阻塞后,操作系统会将它挂起,不会分配CPU。直到数据到达,才会重新分配CPU。只是进程开多了之后,多进程的副作用才明显,因为进程多了,互相切换开销太大。所以,如果一个服务器程序只有1000左右的并发连接,同步阻塞模式是最好的。

异步回调和协程哪个性能好

协程虽然是用户态调度,实际上还是需要调度的。既然存在调度,就存在上下文切换。所以,协程虽然比操作系统进程性能要好,但总还是有额外消耗的。而异步回调是没有切换开销的,它等同于顺序执行代码。所以,异步回调程序的性能是要优于协程模型的性能

参考资料

[1] 为什么QQ用的是UDP协议而不是TCP协议?open in new window [2] 移动端IM/推送系统的协议选型:UDP还是TCP?open in new window [3] 高性能网络编程经典:《The C10K problem(英文)》附件下载open in new window [4] 高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少open in new window [5] 《The C10K problem (英文在线阅读open in new window英文PDF版下载open in new window中文译文open in new window)》 [6] 搜狗实验室技术交流文档《C10K问题探讨》(52im.net).pdfopen in new window (350.83 KB) [7] 【通俗易懂】深入理解TCP协议(上):理论基础open in new window [8] 【通俗易懂】深入理解TCP协议(下):RTT、滑动窗口、拥塞处理open in new window [9] 《TCP/IP详解 卷1:协议 (在线阅读版)》open in new window

网络演变过程总结