杜宝坤

V1

2022/12/04阅读:15主题:全栈蓝

高性能网络编程

前言

基于目前的互联网服务的规模与性能要求,基本所有的互联网服务都是基于集群的、分布式的,那么网络通信就变得尤为重要,高性能的分布式集群服务的基础之一就是高性能的网络服务。

本章针对高性能的网络服务围绕进行介绍,先介绍网络的关键的基础知识,然后介绍下高性能网络的IO模型,并且结合关键的知识点epoll结合实践进行讲解。

一 同步与异步

在学习网络编程的时候,我们经常会听到同步与异步这两个词汇,那么什么是同步?什么是异步呢?它们之间又有什么联系与区别呢?

1.1 同步

所谓同步是指调用者在发出一个功能调用后,在没有得到结果返回之前,该调用者一直进行等待、不返回、亦不继续执行后续操作。

通俗的说,同步就是必须一件一件事情的顺序的做,等前一件做完了才能做下一件事。

例如:举个做饭的流程,结社你用电饭煲做了大米饭,具体过程是:

  1. 淘米并且装入电饭煲
  2. 打开电饭煲开关
  3. 点击煮饭按键
  4. 一直在电饭煲前等待煮饭完成的灯亮起

然后你再去洗菜呀、炖肉呀,等到菜、肉做好,可以愉快的用餐了,整个过程是个串行的过程。

1.2 异步

异步与同步相对,所谓异步是指调用者在发出一个功能调用后,在没有得到结果之前,调用者可以继续执行后续操作。并且这个调用完成后,一般通过状态、通知和回调来通知调用者。对于异步调用,调用的返回并不受调用者控制,仅仅是被通知。

例如:举个做饭的流程,结社你用电饭煲做了大米饭,具体过程是:

  1. 淘米并且装入电饭煲

  2. 打开电饭煲开关

  3. 点击煮饭按键

然后你就可以去做其他的事情了,比如洗菜呀、炖肉呀,不用一直在电饭煲前等待米饭做好,并且在米饭好的时候会有信息提示音,这个时候估计你的菜、肉也做好,可以愉快的用餐了,整个过程是个并行的过程。

1.3 同步与异步的区别

总结来说,同步和异步是一种机制,对应着某个条件的获取的形式的不同,比如上面的做饭的例子,在于如何得知饭是否煲好,如果一直等待进行观察这就是同步,否则通过通知机制(提示音)获取就是异步。

二 I/O模型

上面介绍了同步、异步,下面我们介绍下目前Linux系统中提供的5种IO处理模型

  1. 阻塞IO
  2. 非阻塞IO
  3. IO多路复用
  4. 信号驱动IO
  5. 异步IO

2.1 阻塞IO与非阻塞IO

阻塞和非阻塞这两个概念与程序(线程)等待消息通知时的状态有关。

  1. 阻塞IO

这是最常用的简单的IO模型,阻塞IO操作只能对单个文件描述符进行操作,详见read和write。当调用者发起一次IO操作后

  • 一直等待成功或失败之后才返回。

  • 在这期间程序不能做其它的事情

  1. 非阻塞IO

和阻塞IO一样,非阻塞IO也是通过调用read或write进行操作的,也只能对单个描述符进行操作。

  • 初始化方式:进行非阻塞IO编程时,需要对文件描述符设置O_NONBLOCK来指定该文件描述符的IO操作为非阻塞。

  • 使用方式:非阻塞IO通常发生在一个for循环当中,

    1. 首先,因为在非阻塞模式下,每次进行IO操作时要么IO操作成功,要么当IO操作会阻塞时返回错误EWOULDBLOCK/EAGAIN。

    2. 然后,然后再根据需要进行下一次的for循环操作。

    3. 最后,这种简单的轮询机制会浪费很多不必要的CPU资源,是一种糟糕的设计(需要配合其他机制,后面后有介绍)。

2.2 IO多路复用

IO多路复用简单来说就是单线程或单进程同时监测若干个文件描述符是否可以执行IO操作的能力。

IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个线程。

2.3 信号驱动IO

信号驱动IO是利用信号机制,让内核告知应用程序文件描述符的相关事件。

但信号驱动IO在网络编程的时候通常很少用到,因为在网络环境中,和socket相关的读写事件太多了,比如下面的事件都会导致SIGIO信号的产生:

  1. TCP连接建立

  2. 一方断开TCP连接请求

  3. 断开TCP连接请求完成

  4. TCP连接半关闭

  5. 数据到达TCP socket

上面所有的这些都会产生SIGIO信号,但我们没办法在SIGIO对应的信号处理函数中区分上述不同的事件,SIGIO只应该在IO事件单一情况下使用。

  • 比如用来监听端口的socket,只有发起新连接的时候会产 生SIGIO信号。
  • 比如用来收消息的socket,只有数据到达的时候会产生SIGIO信号。

2.4 异步IO

异步IO(比如使用aio_read来读,使用aio_write来写)和信号驱动IO差不多,但它比信号驱动IO可以多做一步,针对数据拷贝

  • 信号驱动IO的情况下,应用程序需要自己完成数据从用户态到内核态(或反方向)的拷贝;

  • 异步IO的情况下,应用程序只需要提供缓冲区,系统调用完成会自动填充并且通知应用程序。

三 IO多路复用

IO多路复用在linux上主要由三种模式,select、poll与epoll,下面我们介绍下三者的区别。

3.1 时间复杂度

  • select 时间复杂度O(n)

它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。

所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

  • poll 时间复杂度O(n)

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,

但是它没有最大连接数的限制,原因是它是基于链表来存储的.

  • epoll 时间复杂度O(1)

epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。

所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

3.2 实现原理

epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现

  • select:

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:

  1. 单个进程可监视的fd数量被限制,即能监听端口的大小有限。

    一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.

  2. 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:

​ 当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。

  1. 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
  • poll:

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:

  1. 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。

  2. poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

  • epoll:

epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。

  • LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作,
  • ET(边缘触发)模式中,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无 论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读光,也就是说一直读到read的返回值小于请求值,或 遇到EAGAIN错误。
  • epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

三 epoll的核心数据结构与算法

epoll 的核心数据结构是:1个红黑树和1个链表。

3.1 索引红黑树

epoll将“监视队列”和“进程阻塞”分离,这就意味着需要有个数据结构来保存监视的socket。并且需要快速的添加、查询与删除。

红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll 使用了红黑树作为索引结构。

Epoll在linux内核中源码主要为 eventpoll.c 和 eventpoll.h 主要位于fs/eventpoll.c 和 include/linux/eventpool.h,有兴趣的同学可以自行查阅。

epoll使用RB-Tree红黑树去监听并维护所有文件描述符,RB-Tree的根节点。

3.1 就绪队列

epoll 维护着一个就绪队列,当 epoll 监听的 socket 状态发生改变(变为可读或可写)时,就会把就绪的 socket 添加到就绪队列中。如 图3 所示:

就绪列表引用着就绪的socket,所以它应能够快速的插入数据。

调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个 红黑树 用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件.

当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已.

程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。(事实上,每个epoll_item既是红黑树节点,也是链表节点,删除红黑树节点,自然删除了链表节点)

所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。

3.3 epoll API

  1. int epoll_create(int size)

功能:

内核会产生一个epoll 实例数据结构并返回一个文件描述符,这个特殊的描述符就是epoll实例的句柄,后面的两个接口都以它为中心(即epfd形参)。size参数表示所要监视文件描述符的最大值,不过在后来的Linux版本中已经被弃用(同时,size不要传0,会报invalid argument错误)

  1. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

功能:将被监听的描述符添加到红黑树或从红黑树中删除或者对监听事件进行修改

typedef union epoll_data {
void *ptr; /* 指向用户自定义数据 */
int fd; /* 注册的文件描述符 */
uint32_t u32; /* 32-bit integer */
uint64_t u64; /* 64-bit integer */
epoll_data_t;
struct epoll_event {
uint32_t events; /* 描述epoll事件 */
epoll_data_t data; /* 见上面的结构体 */
};

对于需要监视的文件描述符集合,epoll_ctl对红黑树进行管理,红黑树中每个成员由描述符值和所要监控的文件描述符指向的文件表项的引用等组成。

  • op参数说明操作类型:EPOLL_CTL_ADD:向interest list添加一个需要监视的描述符EPOLL_CTL_DEL:从interest list中删除一个描述符EPOLL_CTL_MOD:修改interest list中一个描述符struct epoll_event结构描述一个文件描述符的epoll行为。在使用epoll_wait函数返回处于ready状态的描述符列表时,

  • epoll_event的data域是唯一能给出描述符信息的字段,所以在调用epoll_ctl加入一个需要监测的描述符时,一定要在此域写入描述符相关信息.

  • epoll_event的events域是bit mask,描述一组epoll事件,在epoll_ctl调用中解释为:描述符所期望的epoll事件,可多选。常用的epoll事件描述如下:

    • EPOLLIN:描述符处于可读状态

    • EPOLLOUT:描述符处于可写状态

    • EPOLLET:将epoll event通知模式设置成edge triggered

    • EPOLLONESHOT:第一次进行通知,之后不再监测

  • EPOLLHUP:本端描述符产生一个挂断事件,默认监测事件

    • EPOLLRDHUP:对端描述符产生一个挂断事件

    • EPOLLPRI:由带外数据触发

    • EPOLLERR:描述符产生错误时触发,默认检测事件

  1. int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

功能:阻塞等待注册的事件发生,返回事件的数目,并将触发的事件写入events数组中。

  • events: 用来记录被触发的events,在被监测的文件描述符上实际发生的事件。

  • maxevents: 返回的events的最大个数(处于ready状态的那些文件描述符会被复制进ready list中,epoll_wait用于向用户进程返回ready list。如果ready list比maxevents长,则只能复制前maxevents个成员;反之,则能够完全复制ready list。)

  • 参数timeout描述在函数调用中阻塞时间上限,单位是ms:

    • timeout = -1表示调用将一直阻塞,直到有文件描述符进入ready状态或者捕获到信号才返回;
    • timeout = 0用于非阻塞检测是否有描述符处于ready状态,不管结果怎么样,调用都立即返回;
    • timeout > 0表示调用将最多持续timeout时间,如果期间有检测对象变为ready状态或者捕获到信号则返回,否则直到超时。

四 epoll的水平触发与边缘触发

epoll事件有两种模型:

边沿触发:edge-triggered (ET) 水平触发:level-triggered (LT)

4.1 水平触发(level-triggered)

socket接收缓冲区不为空, 有数据可读, 读事件一直触发。

socket发送缓冲区不满, 可以继续写入数据, 写事件一直触发。

  • 伪代码(假设是加单的一对一交互模式)
1. 首先,使用accept函数接收一个新的连接,并且将该连接添加到epoll中监听EPOLLIN
2. 然后,当某个连接的EPOLLIN事件到达时,read fd中的数据并处理。
3. 然后,因为是简单的交互模式,收到信息后进行恢复,所以需要把数据write到fd中;
4. 然后,注册该连接的EPOLLOUT事件,当EPOLLOUT事件到达时,继续把数据write到fd中;
5. 如果数据写出完毕,那么在epoll中关闭EPOLLOUT事件

4.2 边沿触发(edge-triggered)

socket的接收缓冲区状态变化时,触发读事件,即空的接收缓冲区刚接收到数据时触发读事件。

socket的发送缓冲区状态变化时,触发写事件,即满的缓冲区刚空出空间时触发读事件。

边沿触发仅触发一次,水平触发会一直触发。

  • 伪代码(假设是加单的一对一交互模式)
1. 首先,使用accep函数接收一个新的连接,并且将该连接添加到epoll中监听EPOLLIN|EPOLLET
2. 然后,当某个连接的EPOLLIN事件到达时,read fd中的数据并处理,read需要一直读,直到返回EAGAIN为止(只通知一次,循环读取)
3. 然后,当需要写出数据时,把数据write到fd中,直到数据全部写完,或者write返回EAGAIN,如果没有写完,则设置连接的EPOLLOUT
4. 最后,当EPOLLOUT事件到达时,继续把数据write到fd中,直到数据全部写完,或者write返回EAGAI

五 epoll例子

下面是epolld的例子,单线程、非阻塞IO、LT水平触发,有兴趣的同学可以进行相关的调试与修改。

#include <iostream>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
 
#define MAXLINE 10
#define OPEN_MAX 100
#define LISTENQ 20
#define SERV_PORT 8888
#define INFTIM 1000
 
 
void setnonblocking(int sock)
{
    int opts;
    opts=fcntl(sock,F_GETFL);
    if(opts<0)
    {
        perror("fcntl(sock,GETFL)");
        exit(1);
    }
    opts = opts|O_NONBLOCK;
    if(fcntl(sock,F_SETFL,opts)<0)
    {
        perror("fcntl(sock,SETFL,opts)");
        exit(1);
    }    
}
 
 
int main()
{
    int i, maxi, listenfd, connfd, sockfd, epfd, nfds;
    ssize_t n;
    char line[MAXLINE];
    socklen_t clilen;
     
    struct epoll_event ev,events[20];
    epfd=epoll_create(256);
    struct sockaddr_in clientaddr;
    struct sockaddr_in serveraddr;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    //no block
    setnonblocking( listenfd);
    ev.data.fd = listenfd;
    ev.events = EPOLLIN;
    epoll_ctl( epfd, EPOLL_CTL_ADD, listenfd, &ev);
    bzero( &serveraddr, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
 
    char *local_addr="127.0.0.1";
    inet_aton( local_addr, &(serveraddr.sin_addr));//htons(SERV_PORT);
    serveraddr.sin_port=htons( SERV_PORT);
    bind( listenfd, (sockaddr *)&serveraddr, sizeof(serveraddr));
    listen( listenfd, LISTENQ);
 
    maxi = 0
    for ( ; ; ) 
    {
        nfds=epoll_wait( epfd, events, 20500); 
        for( i=0; i<nfds; ++i)
        {
            if( events[i].data.fd == listenfd)
            {
                connfd = accept( listenfd, (sockaddr *)&clientaddr, &clilen);
                if( connfd < 0)
                {
                    perror( "connfd<0");
                    exit1);
                }
                setnonblocking( connfd);
                char *str = inet_ntoa( clientaddr.sin_addr);
                std::cout << "connect from " << str << std::endl;
                // add
                ev.data.fd = connfd;
                ev.events = EPOLLIN;
                epoll_ctl( epfd, EPOLL_CTL_ADD, connfd, &ev);
                 
                // add
                ev.data.fd = connfd;
                ev.events = EPOLLIN;
                epoll_ctl( epfd, EPOLL_CTL_ADD, connfd, &ev);
             
                // delete 
                epoll_ctl( epfd, EPOLL_CTL_DEL, connfd, &ev);
            }
            else if( events[i].events & EPOLLIN)
            {
                if (( sockfd = events[i].data.fd) < 0
                {
                    continue;
                }
                std::cout << "Data Come!" << std::endl
                if (( n = read(sockfd, line, MAXLINE)) < 0
                {
 
                    if (errno == ECONNRESET)
                    {
                        close(sockfd);
                        events[i].data.fd = -1;
                    } 
                    else
                    {
                        std::cout<<"readline error"<<std::endl;
                    }
 
                } 
                else if (n == 0
                {
                    close(sockfd);
                    events[i].data.fd = -1;
                }
                 
                std::cout << "Data is : " << line << std::endl;
                //epoll_ctl( epfd, EPOLL_CTL_DEL, sockfd, &ev);
                //epoll_ctl( epfd, EPOLL_CTL_DEL, sockfd + 2, &ev);
            }
            else if(events[i].events&EPOLLOUT)
            {    
                sockfd = events[i].data.fd;
                write(sockfd, line, n);
                //设置用于读操作的文件描述符
                ev.data.fd=sockfd;
                //设置用于注测的读操作事件
                ev.events=EPOLLIN|EPOLLET;
                //修改sockfd上要处理的事件为EPOLIN
                epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
            }
        }
    }
}

六 番外篇

个人介绍:杜宝坤,隐私计算行业从业者,从0到1带领团队构建了京东的联邦学习解决方案9N-FL,同时主导了联邦学习框架与联邦开门红业务。 框架层面:实现了电商营销领域支持超大规模的工业化联邦学习解决方案,支持超大规模样本PSI隐私对齐、安全的树模型与神经网络模型等众多模型支持。 业务层面:实现了业务侧的开门红业务落地,开创了新的业务增长点,产生了显著的业务经济效益。 个人比较喜欢学习新东西,乐于钻研技术。基于从全链路思考与决策技术规划的考量,研究的领域比较多,从工程架构、大数据到机器学习算法与算法框架均有涉及。欢迎喜欢技术的同学和我交流,邮箱:baokun06@163.com

七 公众号导读

自己撰写博客已经很长一段时间了,由于个人涉猎的技术领域比较多,所以对高并发与高性能、分布式、传统机器学习算法与框架、深度学习算法与框架、密码安全、隐私计算、联邦学习、大数据等都有涉及。主导过多个大项目包括零售的联邦学习,社区做过多次分享,另外自己坚持写原创博客,多篇文章有过万的阅读。公众号秃顶的码农大家可以按照话题进行连续阅读,里面的章节我都做过按照学习路线的排序,话题就是公众号里面下面的标红的这个,大家点击去就可以看本话题下的多篇文章了,比如下图(话题分为:一、隐私计算 二、联邦学习 三、机器学习框架 四、机器学习算法 五、高性能计算 六、广告算法 七、程序人生),知乎号同理关注专利即可。

一切有为法,如梦幻泡影,如露亦如电,应作如是观。

分类:

后端

标签:

后端

作者介绍

杜宝坤
V1