# Linux下多路IO复用

<font style="background: MediumSpringGreen">
<font style="background:yellow">
1
2

[TOC]

面试官建议关注

高并发、TCP的多收和多发

<font style="background:yellow">
1

# C100K和C1000K

如何做到单机支持 C10M。参考资料 (opens new window)

  • 注意,C10K 和 C1000K 的首字母 C 是 Client 的缩写
    • C10K 就是单机同时处理 1 万个请求(并发连接 1 万**(因为1K=1000, 那么10K显然就是1W)** )的问题
    • 而 C1000K 也就是单机支持处理 100 万个请求(并发连接 100 万)的问题。

# 1.什么是阻塞(Block)轮询(poll)?

阻塞后可以进行进程调度,CPU利用率高 阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态

  • 阻塞调用是指调用结果返回之前,==当前线程会被挂起==。调用线程只有在得到结果之后才会返回

  • 还是上面的例子,你打电话问书店老板有没有《分布式系统》这本书,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结果

  • 非阻塞调用指在不能立刻得到结果之前,该调用==不会阻塞当前线程==

    • 如果是非阻塞式调用,你不管老板有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下老板有没有返回结果

    在这里阻塞与非阻塞与是否同步异步无关。

    跟老板通过什么方式回答你结果无关

# 轮询(poll)

  • 马上返回,过一会后,再重新问,可以吗

# 2.数据到达和数据拷贝

  • 数据到达,是借助网络,数据读入到本地的『内核』—因为4层模型中,除了『应用层』其他都是kernel态的
  • 内核到(用户)user区数据拷贝:注意这个

上面,2个阶段是理解5种IO模型的关键!『参考自Cyc2018』

image-20210713165516640

Linux多线程服务器编程,PDF,网上搜集『IO多路复用』

# ⭐️笔记

# I/O多路复用(I/O多路转接)

  • I/O 多路复用使得程序能『同时监听』多个文件描述符,能够提高程序的性能
  • Linux 下实现 I/O 多路复用的『系统调用』主要有 select、poll 和 epoll

# select

主旨思想:

1.首先要构造一个关于文件描述符列表【fd_set类型】,将要监听的文件描述符添加【使用宏去set】到该列表中。
2.调用一个系统函数【调用select系统函数】,监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O 操作时,该函数才返回。

  • a.这个函数是阻塞【调用结果返回之前,==当前线程会被挂起==】
  • b.函数对文件描述符的检测的操作是由内核完成的

3.在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作

【4.然后用户还得在用户态继续遍历,原先的fd_set类型数组】

// sizeof(fd_set) = 128字节 	=8*128 = 1024 比特「也就是bitmap」
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/select.h>

int select(int nfds , fd_set *readfds, fd_set *writefds, 
           fd_set *exceptfds, struct timeval *timeout);
	- 参数:
		- nfds : 委托内核检测的最大文件描述符的值 + 1
		- readfds : 要检测的文件描述符的读的集合,委托内核检测哪些文件描述符的读的属性
		- 一般检测读操作
		- 对应的是对方发送过来的数据,因为读是被动的接收数据,检测的就是读缓冲区
		- 是一个传入传出参数
	- writefds : 要检测的文件描述符的写的集合,委托内核检测哪些文件描述符的写的属性
		- 委托内核检测写缓冲区是不是还可以写数据(不满的就可以写)
	- exceptfds : 检测发生异常的文件描述符的集合
	- timeout : 设置的超时时间【超时时间!用途,比如,本来阻塞是为了死等后才唤醒线程,现在超时了,你可以选择继续调用函数死等还是算了,去执行其他逻辑】
        
        struct timeval {
        long tv_sec; 		/* seconds */
        long tv_usec; 		/* microseconds */
        };
        - NULL : 永久阻塞【也就是我们正常的死等,正常的阻塞】,直到检测到了文件描述符有变化
        - tv_sec = 0 tv_usec = 0, 不阻塞【也就是,可以通过修改这个超时时间,select也可以是非阻塞函数了】
        - tv_sec > 0 tv_usec > 0, 阻塞对应的时间
            
    - 返回值 :
        - -1 : 失败
        - >0(n) : 检测的集合中有n个文件描述符发生了变化
    

// 将参数文件描述符fd对应的标志位设置为0
void FD_CLR(int fd, fd_set *set);

// 判断fd对应的标志位是0还是1, 返回值 : fd对应的标志位的值,0,返回0, 1,返回1
int FD_ISSET(int fd, fd_set *set);

// 将参数文件描述符fd 对应的标志位,设置为1
void FD_SET(int fd, fd_set *set);

// fd_set一共有1024 bit, 全部初始化为0
void FD_ZERO(fd_set *set);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

# 结构体

  • bits/typesizes.h
/* Number of descriptors that can fit in an `fd_set'.  */
#define __FD_SETSIZE            1024
1
2
  • sys/select.h
typedef long int __fd_mask;
/* It's easier to assume 8-bit bytes than to get CHAR_BIT.  */
#define __NFDBITS       (8 * (int) sizeof (__fd_mask))

typedef struct
{
	 __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;
1
2
3
4
5
6
7
8
//宏定义的替换后
typedef struct
{
	 long int fds_bits[ 1024 / (8* sizeof(long int)) ];
} fd_set;

//假设是32位的机器,sizeof(long int)=4Btye相当于int
typedef struct
{
	 int fds_bits[ 32 ];
} fd_set;

sizeof(fd_set)=sizeof( fds_bits )= 4*32= 128 Bytes
  128 Bytes=128*8=1024 bits
  

//假设是64位的机器,sizeof(long int)=4Btye相当于long long
typedef struct
{
	 long long fds_bits[ 16 ];
} fd_set;

sizeof(fd_set)=sizeof( fds_bits )= 8*16= 128 Bytes
  128 Bytes=128*8=1024 bits
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 指针集合

typedef struct {
  //3个指针,分别指向 输入的:读,写,异常  3个文件描述符集合
	unsigned long *in, *out, *ex; 
  //步长为sizeof(unsigned long)的指针,如果32位就是4bytes,如果64位就是8bytes
  
  //3个指针,分别指向 输出的结果:读,写,异常  3个文件描述符集合
	unsigned long *res_in, *res_out, *res_ex;
} fd_set_bits;
1
2
3
4
5
6
7
8

# 核心源码实现

  • SELECT_STACK_ALLOC=256
  • 原因是https://github.com/torvalds/linux/blob/v5.4/include/linux/poll.h
#define FRONTEND_STACK_ALLOC	256
#define SELECT_STACK_ALLOC	FRONTEND_STACK_ALLOC

#define POLL_STACK_ALLOC	FRONTEND_STACK_ALLOC
#define WQUEUES_STACK_ALLOC	(MAX_STACK_ALLOC - FRONTEND_STACK_ALLOC)
1
2
3
4
5
/* Allocate small arguments on the stack to save memory and be faster */
	long stack_fds[SELECT_STACK_ALLOC/sizeof(long)];

//32位就是
//int stack_fds[ 256 /sizeof(int) ]
//int stack_fds[ 64 ]
// sizeof(stack_fds)= 4*64= 256Btypes
// 256 * 8 = 4096 bits
1
2
3
4
5
6
7
8
  • https://github.com/torvalds/linux/blob/v5.4/include/linux/fdtable.h
struct fdtable {
	unsigned int max_fds; //最大文件描述符
	struct file __rcu **fd;      /* current fd array */
	unsigned long *close_on_exec;
	unsigned long *open_fds;
	unsigned long *full_fds_bits;
	struct rcu_head rcu;
};
1
2
3
4
5
6
7
8

我们需要6个位图(输入和输出均为in/out/ex),因为我们使用fdset,所以需要以长字为单位分配内存。(in units of long-words)

/*
 * How many longwords for "nr" bits?
 */
#define FDS_BITPERLONG	(8*sizeof(long)) //这个是8* sizeof的字节 = 比特 

FDS_BITPERLONG=8 * 4 或者 8 * 8

#define FDS_LONGS(nr)	(  (  (nr)+FDS_BITPERLONG-1  )  / FDS_BITPERLONG )

#define FDS_BYTES(nr)	(FDS_LONGS(nr)*sizeof(long))

  FDS_BYTES(n) =(n)+FDS_BITPERLONG-1)/FDS_BITPERLONG 】 * sizeof(long)
  						  
  //32位
  FDS_BYTES(n) =(n+31)/32*4

// n 个文件需要的字节数,也是每份位图的大小
    size = FDS_BYTES(n);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 如果栈上分配的空间不够,要使用 kmalloc去分配
    • 后续代码中,发现如果地址不是stack地址,我们会kfree那个kmalloc的东西

# poll

#include <poll.h>

struct pollfd {
    int fd; 		/* 委托内核检测的文件描述符 */
    short events; 	/* 委托内核检测文件描述符的什么事件 */
    short revents; 	/* 文件描述符实际发生的事件 */
};

struct pollfd myfd;
myfd.fd = 5;
myfd.events = POLLIN | POLLOUT;


int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 参数:
    - fds : 是一个struct pollfd 结构体数组,这是一个需要检测的文件描述符的集合
    - nfds : 这个是第一个参数数组中最后一个有效元素的下标 + 1
    - timeout : 阻塞时长
        0 : 不阻塞
        -1 : 阻塞,当检测到需要检测的文件描述符有变化,解除阻塞
        >0 : 阻塞的时长
            
- 返回值:
    -1 : 失败
    >0(n) : 成功,n表示检测到集合中有n个文件描述符发生变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

image-20210713164017770

# epoll

#include <sys/epoll.h>

// 创建一个新的epoll实例。在内核中创建了一个数据,这个数据中有两个比较重要的数据,一个是需要检
测的文件描述符的信息(红黑树),还有一个是就绪列表,存放检测到数据发送改变的文件描述符信息(双向
链表)。
    
int epoll_create(int size);
    - 参数:
    	size : 目前没有意义了。随便写一个数,必须大于0
    - 返回值:
        -1 : 失败
        > 0 : 文件描述符,操作epoll实例的

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 */
};

常见的Epoll检测事件:
    - EPOLLIN
    - EPOLLOUT
    - EPOLLERR
    - EPOLLET
    
// 对epoll实例进行管理:添加文件描述符信息,删除信息,修改信息
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    - 参数:
        - epfd : epoll实例对应的文件描述符
        - op : 要进行什么操作
            EPOLL_CTL_ADD: 添加
            EPOLL_CTL_MOD: 修改
            EPOLL_CTL_DEL: 删除
    - fd : 要检测的文件描述符
    - event : 检测文件描述符什么事情
        
// 检测函数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int
timeout);
    - 参数:
    - epfd : epoll实例对应的文件描述符
    - events : 传出参数,保存了发送了变化的文件描述符的信息
    - maxevents : 第二个参数结构体数组的大小
    - timeout : 阻塞时间
        - 0 : 不阻塞
        - -1 : 阻塞,直到检测到fd数据发生变化,解除阻塞
        - > 0 : 阻塞的时长(毫秒)
        
    - 返回值:
        - 成功,返回发送变化的文件描述符的个数 > 0
        - 失败 -1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

Epoll 的工作模式:

  • LT 模式 (水平触发) 假设委托内核检测读事件→检测fd的读缓冲区 读缓冲区有数据→epoll检测到了会给用户通知 a.用户不读数据,数据一直在缓冲区,epoll 会一直通知 b.用户只读了一部分数据,epoll会通知 c.缓冲区的数据读完了,不通知

    LT(level - triggered)是缺省的工作方式,并且同时支持 block 和 no-block socket。在这 种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操 作。如果你不作任何操作,内核还是会继续通知你的。

  • ET 模式(边沿触发)

    假设委托内核检测读事件→检测fd的读缓冲区

    读缓冲区有数据→epoll检测到了会给用户通知

    ​ a.用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了

    ​ b.用户只读了一部分数据,epoll不通知

    ​ c.缓冲区的数据读完了,不通知

    ET(edge - triggered)是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成 未就绪),内核不会发送更多的通知(only once)。

    ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写 操作把处理多个文件描述符的任务饿死。

# 水平触发和边缘触发的「图解」

计算机行业水平触发与边缘触发名字的来源?

计算机行业中的“水平触发”(Level-Triggered)和“边缘触发”(Edge-Triggered)是指硬件或软件在检测到某个输入信号的特定状态时触发相应的操作。这些术语的名称源自电子工程中的相关概念:

  • 水平触发:在电路中,信号被视为“高电平”或“低电平”,并保持在这种状态直到信号发生变化。因此,当检测到信号处于特定状态时,该信号被认为是“水平触发”的。
  • 边缘触发:相比之下,边缘触发在信号发生变化(即电平上升或下降)时触发操作。在电子工程中,这些变化通常称为“边沿”(Edge),因此此类触发被称为“边缘触发”。

在计算机行业中,这些术语通常用于描述硬件中的中断处理,例如 CPU 中断控制器,以及软件中的事件处理,例如在操作系统中注册的回调函数。在这些情况下,水平触发和边缘触发可以用来指定触发中断或事件处理程序的方式。

最好的解释:

使用脉冲信号来解释LT和ET可能更加贴切。

Level是指信号只需要处于水平,就一直会触发

而edge则是指信号为上升沿或者下降沿时触发。

  • 我花了一个图来理解!

# 为什么需要多路转接?

涉及高性能服务器开发方面 ,epoll是非常常用的系统调用,在很多的开源项目当中epoll都是核心技术,例如Nginx和Redis等等

目前epoll是linux大规模并发网络程序中的热门首选模型

# 2.1.多线程和多进程服务器特点

多线程和多进程效率低的原因:所有监听都是serve.c这个程序自己来做。

有一种好的方法:多路IO转接服务器也叫做多任务(event)IO服务器

该类服务器实现的主旨思想是,不再由『应用程序』自己监视客户端连接,取而代之由内核替应用程序监视文件。

  • 重点在“转接”
    • 多路指的,多个访问请求的客户端
    • 转接:言外之意,中间有个人帮我进行转换,转换客户端的请求,转接给我,然后我再进行操作。

# 2.2.学习select/poll/epoll的原因

原因:CPU消耗大,主要用在CPU切换上,『CPU上下文切换』,是需要操作一些句柄的,代价高。

多线程和多进程并发,不适合,客户端非常庞大的情况。 原因:开销大,占用资源 详细点:他主要占用的资源,CPU消耗得大

起几百个进程,几百个线程,实际上对于Linux操作系统来说,在内核消耗上或者系统内部的消耗上。 差别不是特别大。 当然,我们知道,多线程比多进程好一些

上面,如果我们发现,那就是,如果每个客户端都和我进行数据交换。 那我的CPU岂不是就要频繁的进行切换

  • 如何把这个降低呢?
  • 才有多路IO转接模型,有三种(难度大一些了,使用起来,也比较不好理解,然后代码量相对来说,大一些

# 1、建立连接的『时候』的好处

  • 把内核请过来给我当帮手,我只需要根据我的设置请求,通知他,你去帮我监听这几个客户端,是否有请求。当这些客户端有请求的时候,帮助我监听的内核,会给我一个反馈,意思告诉我说,他们要对你发起请求了。)

  • 当我的监听的人,给我反馈的时候,我再去处理,我需不要等待?? 不需要。比如,我们原先去处理的时候,我要调用accept函数,这个函数会阻塞(或者说等待,等待,客户端给我发链接)

  • 但是,现在阻塞等待这个事情,我让内核去干了。内核什么时候会给我反馈呢?那就是有人给我有连接请求了。

  • 所以说,当他给了我反馈的时候,我还需要等待吗?不需要!可以立即完成连接的建立。

# 2、建立连接之『后』的好处

  • 我们连接之后的那条线,我们也可以把它丢给内核,要不然的话,我还得阻塞在这继续监听,是否给我发数据。
  • 内核你帮我,反正你监听一个也是听,听一堆也是听。监听到有请求的时候,再给我发一个反馈。当我收到反馈的时候,我应该干啥?
  • 表示对方已经写过了数据啥的了,都已经写到这边了,我所要做的事情就是,在这个基础上,直接和对方去通信(read)
  • 如上,内核的这种行为有点像:我们先前讲信号的时候。注册那个信号捕捉函数。 内核给你反馈了,就说明有事件发生了,但是具体是什么事件,还是不知道。 比如,我向内核注册一下,帮我看着几个人, 当内核你看到他有行动的时候,你再告诉我。
  • 有了内核帮我监听,那么我这个serve.c就解放出来了。
    • 我就不需要再阻塞了,我设置完监听之后,就可以去做其他的时候了。
    • 上面就是多路IO转接的基本思想。
    • 我们要讲的3种,思路几乎和这个一致。

# 3.select详解

特点:

1、用『数组』实现,文件描述符受到控制

3、超时的时间控制在纳秒级别『ns』

具体实施的话,驱使我们先前的那个内核工作的,是谁来**驱使(需要用到函数,比如select)**呢?

如何使用起来select

select
1.	select能监听的文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数
2.	解决1024以下客户端时使用select是很合适的,但如果链接客户端过多,select采用的是轮询模型,会大大降低服务器响应效率,不应在select上投入更多精力
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
			fd_set *exceptfds, struct timeval *timeout);

	nfds: 		监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
	readfds:	监控有读数据到达文件描述符集合,传入传出参数
	writefds:	监控写数据到达文件描述符集合,传入传出参数
	exceptfds:	监控异常发生达文件描述符集合,如带外数据到达异常,传入传出参数
	timeout:	定时阻塞监控时间,3种情况
				1.NULL,永远等下去
				2.设置timeval,等待固定时间
				3.设置timeval里时间均为0,检查描述字后立即返回,轮询
	struct timeval {
		long tv_sec; /* seconds */
		long tv_usec; /* microseconds */
	};
	void FD_CLR(int fd, fd_set *set); 	//把文件描述符集合里fd清0
	int FD_ISSET(int fd, fd_set *set); 	//测试文件描述符集合里fd是否置1
	void FD_SET(int fd, fd_set *set); 	//把文件描述符集合里fd位置1
	void FD_ZERO(fd_set *set); 			//把文件描述符集合里所有位清0
//上面4个函数重要。有的函数,有点类似信号那部分的啥的?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

select函数一调,相当于,select函数会驱使内核帮助你完成监听。 笼统的说,那就是借助select帮我监听、

# 4.poll详解

特点:

1、实际上是在select这种模型上进行的一种升级,或者说,是改版。

2、函数原型比select简单

3、超时的时间控制在毫秒级别『ms』

# 5.epoll『Linux专用』

彻底学会epoll模式 (opens new window)

  • epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。
    目前epell是linux大规模并发网络程序中的热门首选模型。
  • epoll除了提供select/poll那种IO事件的**电平触发(Level Triggered)**外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
  • 多路IO转接服务器

要掌握epoll与前面的select和poll的区别和联系,以及,epoll的缺点。
epoll的两种模式:epoll ET 和epoll LT

# ET(Edge Triggered,边沿触发)

边沿触发。 event = EPOLLIN | EPOLLET

# LT(Level Triggered,电平触发/水平触发)

​ 水平触发。

epoll 非阻塞IO

	边沿触发    while(read())   fcntl(O_NONBLOCK);


epoll 反应堆模型 (libevent 核心思想实现)

	libevent  -- 跨平台   精炼--epoll  回调   

	1. epoll --- 服务器 --- 监听 --- fd ----可读 ---- epoll返回 ---- read --- 小写转大写 --- write ---- epoll继续监听。

	2. epoll 反应堆模型: ("滑动窗口")

	1) epoll --- 服务器 --- 监听 --- cfd ---- 可读 ---- epoll返回 ---- read -- cfd从树上摘下 --- 设置监听cfd写事件, 操作