欢迎光临散文网 会员登陆 & 注册

IO多路复用是什么?如何设计一个高性能服务器?

2023-07-12 00:25 作者:棂傀-ghost_him  | 我要投稿

笔记同步到了我的个人网站上,b站好像不支持markdown语法,所以为了一个更好的体验,欢迎前来围观(❁´◡`❁):http://www.ghost-him.com/posts/5203630b/


文中代码采用 c 风格,linux 下的系统调用的名称。只采用伪代码的形式编写,无法运行。


<!-- more -->


## 阻塞 IO 形式


最简单的服务器形式


伪代码形式:


```cpp

server_fd = socket(); // 创建一个socket

bind(server_fd, "0.0.0.0", 8080); // 将当前的socket绑定到一个指定的地址和端口

listen(server_fd) // 监听连接请求


while (1) {

client_fd = accept(server_fd); // 接受连接请求

if (read(client_fd, buff)) { // 从client_fd中读取数据,并将数据存放到buff中

handler(buff) // 处理当前buff中的数据

} else {

close(client_fd) // 如果已经读完了,则关闭连接

}

}

```


特点:一次只可以处理一个连接,处理完以后才可以接受下一个连接的请求。

原因:这里的 `accept` 函数和 `read` 函数都是阻塞的。


如果要支持多个客户端的连接请求,那么可以对代码做一些改进:


```cpp

server_fd = socket(); // 创建一个socket

bind(server_fd, "0.0.0.0", 8080); // 将当前的socket绑定到一个指定的地址和端口

listen(server_fd) // 监听连接请求


while (1) {

client_fd = accept(server_fd); // 接受连接请求

fds.add(client_fd); // 将当前的新连接的fd添加到fds的连接数组中

for (fd : fds) { // 遍历当前fds中的所有的fd

if (read(fd, buff)) { // 从client_fd中读取数据,并将数据存放到buff中

handler(buff) // 处理当前buff中的数据

} else {

close(fd) // 如果已经读完了,则关闭连接

}

}

}

```


代码存在的问题:

1. 如果在等待新的连接时,无法处理已经连接上的请求。

2. 同理,如果在等待已经连接上的 fd 传输数据时,无法连接新的请求。

3. 如果在遍历 fds 时,如果其中的一个 fd 一直没有传输数据过来,那么整个程序会卡死(一直阻塞在 read 函数)。


产生问题的原因:`read` 函数和 `accept` 函数相互影响导致的。


解决办法:引入多线程。


## 阻塞 IO+多线程


```cpp

server_fd = socket(); // 创建一个socket

bind(server_fd, "0.0.0.0", 8080); // 将当前的socket绑定到一个指定的地址和端口

listen(server_fd) // 监听连接请求


while (1) {

client_fd = accept(server_fd); // 接受连接请求

pthread_create(client_fd){ // 创建一个新的线程

while(1) {

if (read(client_fd, buff)) { // 从client_fd中读取数据,并将数据存放到buff中

handler(buff) // 处理当前buff中的数据

} else {

close(client_fd) // 如果已经读完了,则关闭连接

}

}

}

}

```


特点:

1. 可以实现一个可用的多线程 tcp 服务器,同时支持处理多个客户端的连接请求。

2. 一个线程处理一个连接


缺点:

1. 无法处理大量连接


原因:每个线程都会占用一定的资源(时,空),所以不但可以创建的线程数是有限的,而且上下文切换的也会占用大量的时间,会影响处理的效率。


解决方法:使用线程池来代替大量的线程


```cpp

server_fd = socket(); // 创建一个socket

bind(server_fd, "0.0.0.0", 8080); // 将当前的socket绑定到一个指定的地址和端口

listen(server_fd) // 监听连接请求

thread_pool_create(num) // 创建指定数量的线程


while (1) {

client_fd = accept(server_fd); // 接受连接请求

thread_pool_get(client_fd) { // 从线程池中获取一个线程

while(1) {

if (read(client_fd, buff)) { // 从client_fd中读取数据,并将数据存放到buff中

handler(buff) // 处理当前buff中的数据

} else {

close(client_fd) // 如果已经读完了,则关闭连接

break; // 跳出循环,将线程放回线程池 

}

}

}

}

```


缺点:

1. 获取线程 `thread_pool_get` 是一个阻塞的函数,所以会影响服务器的处理能力。

2. 在高并发的环境下,主线程会由于线程池中的线程的数量受到限制,从而无法处理新的请求(直到有旧的连接关闭,线程释放)


原因:每个连接都要一个线程处理,而线程池中的线程是有限的,所以线程池的大小就决定了同时在线连接数的数量。


解决办法:

1. 部署更多的服务器



## 非阻塞 IO


对于一个网络 IO,共有两个系统对象,一个是应用进程,一个是系统内核。当一个 read 函数发生时,会有两个阶段:

1. 等待数据准备

2. 将数据从内核拷贝到用户空间

在阻塞 IO 模型中,只有当这两个阶段都完成了以后都会返回。


所以这里就是一个可以优化的地方。


在非阻塞 IO 模型中,当应用线程发出 read 系统调用的时候,如果内核中的数据还没有准备好,他并不会去阻塞应用的线程,而是返回一个错误。对于应用线程来说,发出一个 read 系统调用以后不需要等待,就可以得到一个结果。如果这个结果是一个错误,那么就说明当前还没有准备好,于是,可以再次发送一个 read 操作。当数据已经准备好了,并且应用线程发送了一个 read 系统调用的时候,内核会将数据拷贝到应用进程,拷贝完以后再返回成功。


因此,对于阻塞模型与非阻塞模型来说,不同的地方在于第一阶段,第二阶段下,两个模型都是一样的。


```cpp

server_fd = socket(); // 创建一个socket

bind(server_fd, "0.0.0.0", 8080); // 将当前的socket绑定到一个指定的地址和端口

listen(server_fd) // 监听连接请求

set_non_block(server_fd) // 设置成非阻塞


while (1) {

client_fd = accept(server_fd); // 接受连接请求

if (client_fd > 0) {

set_non_block(client_fd); // 设置非阻塞模式

fds.add(client_fd); // 新的fd加入到fds中

}

for (fd : fds) { // 遍历当前fds中的所有的fd

n = read(fd, buff); // 非阻塞的读取数据

if (n == -1) {

continue; // 无数据可读

} else if (n == 0) { // 连接关闭

close(fd); // 断开连接

} else {

handler(buff) // 读到数据逻辑处理

}

}

}

```


缺点:`while` 循环中会不断的向系统询问,系统的开销很大,同时会占用大量的 cpu 资源。


## IO 多路复用


目的:避免应用线程循环检查发起系统调用的开销


原理:将需要监听的文件描述符,通过一个系统 (select, poll, epoll 等)一直传递到内核中,由内核来监视这些文件描述符。当其中的任意一个文件描述符发生了 I/O 事件(读,写,连接,关闭等),内核就会通知应用程序进行处理。


多路是指需要处理的多个连接的 I/O 事件,复用是指复用一个或少量的线程资源。I/O 多路复用就是用一个或者少量的线程资源去处理多个连接的 I/O 事件。


使用 select 函数来举例:

```cpp

server_fd = socket();

bind(server_fd, "0,0,0,0", 8080);

listen(server_Fd);

readfds; // 待监听的集合

client_fds; // 连接描述符数组


while (1) {

// 清空集合

FD_ZERO(&readfds);

// 添加server_Fd 到集合中

FD_SET(server_Fd. &readfds);

// 遍历连接fd集合

for (fd : client_fds) {

//将有效的fd添加到集合中

if (fd) {

FD_SET(fd, &readfds);

}

}


// 阻塞等待fd上的IO事件

select(fd_num, &readfds, NULL, NULL, NULL);

// 如果server_fd有事件,则有新的连接

if (FD_ISSET(server_fd, &readfds)) {

client_fd = accept(server_fd);

// 新的fd加入到数组中

client_fds.add(client_fd);

}


for (fd : client_fds) {

// 如果有IO事件

if (FD_ISSET(fd, &readfds)) {

if (read(fd, buff)) {

handler(buff);

} else {

// 连接关闭 

close(fd);

// 从集合中移除

client_fds.remove(fd);

}

}

}

}

```


优点:避免了主动的轮询,减少了 cpu 的占用。


缺点:

1. 每次在调用 select 函数都需要重新初始化待监听描述符集合

2. 每次都要将描述符集合拷贝到内核中

3. Select 返回后需要遍历所有文件描述符,依次检查是否就绪。(即使就一个准备好,也要遍历全部)

4. 最多只可以监听 1024 个文件描述符


`poll` 对第 1 点和第 4 点做了优化。`epoll` 对所有的缺点都进行了优化。`epoll` 使用内核空间和用户空间共享的内存区来传递文件描述符,避免了从用户态向内核态拷贝的开销。同时,不需要遍历全部文件描述符,因为它只将发生变动的文件描述符返回。


**注:看评论区说 epoll 会将数据从内核拷贝到用户空间,并不是共享内存实现的**

IO多路复用是什么?如何设计一个高性能服务器?的评论 (共 条)

分享到微博请遵守国家法律