网络编程总结
前言
从一个例子出发:
假设客户端希望从服务器下载一个名为 file.txt 的文件,整个过程大致如下:
-
客户端发起 HTTP 请求:
客户端通过向服务器发送一个 HTTP 请求来获取文件。请求的格式如下:GET /file.txt HTTP/1.1 Host: www.oy.com
客户端将通过 URL 解析和 DNS 查询,获取到目标服务器的 IP 地址。 -
建立 TCP 连接(TCP 三次握手):
在发送 HTTP 请求之前,客户端和服务器需要通过 TCP 协议建立一个可靠的连接。三次握手的过程如下:
- 客户端发送 SYN 请求,向服务器提出连接请求。
- 服务器回复 SYN-ACK,确认连接请求。
- 客户端再次发送 ACK,表示连接建立完成。
-
客户端发送 HTTP 请求:
建立 TCP 连接后,客户端通过该连接发送 HTTP 请求,请求服务器提供file.txt文件。 -
服务器处理请求并发送响应:
服务器接收到请求后,从磁盘读取文件内容(例如,file.txt),并通过 TCP 连接将文件内容作为 HTTP 响应回传给客户端。为了提高性能,服务器可能会使用零拷贝技术,以避免重复的内存拷贝操作。 -
客户端接收响应并关闭连接:
客户端通过 TCP 协议接收响应,并将文件保存到本地。当文件接收完毕后,客户端可以发送一个 FIN 请求,开始断开连接。服务器确认后,完成四次挥手,安全地断开连接。
在上面的第四步服务器处理请求并发送响应时,系统往往要进行IO处理,常见的IO模型如下:
I/O模型
核心概念:
阻塞与非阻塞
个人理解:阻塞和非阻塞一般讨论的是面对请求时的一种状态
阻塞(Blocking):
阻塞 I/O 模型是指在 I/O 操作执行期间,程序会被阻塞,直到该操作完成。在此期间,进程无法执行其他任务。
例如:
服务器通过read() 系统调用来读取硬盘中的数据,当我们请求的资源不可用时(资源被占用,没有数据到达等等),会使得进程休眠,从现象看就是卡在那里。
非阻塞(Non-blocking):
- 非阻塞 I/O 模型是指在 I/O 操作过程中,程序不会被阻塞。如果没有数据可以读取或写入,调用会立即返回,而不是等待 I/O 操作完成。
- 使用非阻塞 I/O 时,程序可以进行轮询或采取其他方法来检查是否可以继续处理 I/O 操作。
例如:
服务器程序可以通过设置O_NONBLOCK 标志将文件描述符设置为非阻塞模式,在这种模式下,如果 read() 无法立即完成,它们会立即返回而不会阻塞进程。
同步与异步
对于同步和异步,往往需要讨论的是多任务的场景,还得先引入并发,并行的概念。
并发:多个任务在同一个时间段内同时执行,如果是单核心计算机,CPU 会不断地切换任务来完成并发操作。
并行:多任务在同一个时刻同时执行,计算机需要有多核心,每个核心独立执行一个任务,多个任务同时执行,不需要切换。
同步:多任务开始执行,任务 A、B、C 全部执行完成后才算是结束。
异步:多任务开始执行,只需要主任务 A 执行完成就算结束,主任务执行的时候,可以同时执行异步任务 B、C,主任务 A 可以不需要等待异步任务 B、C 的结果。
一般系统中的同步,可以通过串行来实现,也就是一个任务一个任务处理,而异步可以通过并发或者并行。
五种模型
在网络IO的处理中,服务器还需要经过以下过程:

1. 阻塞IO模式
从上面的解释就可以知道,当服务器进程read读取消息内容时,如果该进程请求的资源不可用时(资源被占用,没有数据到达等等),会使得进程休眠,从现象看就是卡在那里。
2. 非阻塞IO模式
如果服务器进程将read设置为非阻塞,在读取消息数据时,会立刻返回读取的结果,不会等待。
3. 多路复用IO模式
讨论并发的情况:若我们服务器进程想要处理多个客户端发送请求,此时需要靠多线程来帮助实现,同时,我们的线程无法知道什么时候会有数据到达,因此我们多个的线程只能不断的去进询问,查看缓冲区是否有数据可读。这带来了两个问题:
- 如果请求数量过多,消耗的线程数量巨大
- 多个线程需要一直查询,无数据可读时,其余时间白白浪费了资源
因此,我们希望有个方法,只需要少量的资源,例如一个进程或者一个线程就能监控这庞大的请求。linux中提供了三个系统函数:select、poll、epoll。后续在详细讨论三个函数内容。简单来说,有了这些方法,我们的服务器进程可以调用一个它们就能监控多个客户端的请求,只要有资源可读时,函数就能返回可读的状态,这样避免了资源的浪费。
4. 信号驱动IO
在上面的多路复用IO模型中,都需要通过轮询的方法去进行检查可读状态,例如select,poll轮询所有文件描述符,而epoll是事件驱动,只监测变化的事件。它们的本质还是得需要一个线程去持续的监督。
而信号驱动 I/O 是通过 信号 通知进程或线程某个 I/O 操作已经完成。当 I/O 完成时,操作系统会向应用程序发送一个信号(如 SIGIO),通知它进行相应的处理。
信号驱动 I/O 通过异步的方式来通知进程,进程在收到信号时才会进行 I/O 操作。
5. 异步IO
以上讨论的四种办法中,都是进程在得知可读状态之后再去读取数据,那么为什么不能直接让系统去处理这个接受和读取数据的过程呢。
在前言部分已经解释了这个异步的概念。我们的进程不再负责数据的询问和读取,将这部分内容交给了内核去处理,主进程通知内核去读取数据,然后就去执行其他任务,当内核读取数据结束之后,通知主进程,主进程再处理这些数据,或者让线程处理。
一些关键区别:
信号驱动IO和异步IO的区别:数据读取方是谁。
在得知数据可读的这个过程,两者其实都是异步的,即都不需要主进程参与,但在得知数据可读之后,信号驱动IO还是需要主进程去读取这个数据,而异步IO模型是由系统读取完了直接通知主进程。
关键系统函数
零拷贝
当我们服务器程序需要发送数据时,其过程如下:

一个文件的发送需要以下过程:
-
调用读取函数,切换内核态,将数据从磁盘拷贝至缓冲区
-
切换到用户态,将数据从缓冲区读取至进程缓冲区中。
-
将数据从进程缓冲区拷贝至socket缓冲区。
-
最后再拷贝至网卡中,再由网卡进行数据发送
可以看出浪费了许多时间。
在linux中提供了两个方法:
mmap
- 应用进程调用了
mmap()后,系统会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系
统内核「共享」这个缓冲区; - 应用进程再调用
write(),操作系统直接将内核缓冲区的数据拷贝到socket缓冲区中,这一切都发生在内核态,由 CPU来搬运数据; - 最后,系统把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里。
sendfile
函数形式如下:
1 |
|
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
- 应用进程调用了
sendfile()之后,系统会把磁盘的数据拷贝到内核的缓冲区里。然后操作系统直接将内核缓冲区的数据拷贝到socket缓冲区中,这一切都发生在内核态,由CPU来搬运数据,不在需要write函数 - 最后,系统把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里。
复用IO函数
1. select
概述:
select 是最早的 I/O 多路复用机制之一,最早在 BSD Unix 中引入,并广泛应用于各类操作系统(包括 Linux)。它允许程序在多个文件描述符上等待 I/O 事件的发生,并在事件发生时通知程序。
工作原理:
- 程序调用
select函数并传递三个集合(readfds、writefds、exceptfds),分别表示要监控的可读、可写和异常状态的文件描述符。 select阻塞直到至少一个文件描述符的状态发生变化(如可读、可写或异常)。- 当
select返回时,它会告诉程序哪些文件描述符可以进行相应的操作。
优缺点:
- 优点:
- 简单易用,广泛支持,几乎在所有操作系统上都可用。
- 缺点:
- 性能问题:当监控的文件描述符数目很大时,
select会非常低效。每次调用时,程序需要将所有文件描述符从用户空间复制到内核空间,并且内核需要遍历所有的文件描述符进行检查。这样,文件描述符数目越多,性能就越差。 - 文件描述符数量限制:
select的文件描述符数量有上限(通常是FD_SETSIZE,在 Linux 上通常是 1024)。这意味着,如果要监控更多的连接,需要对代码进行修改或使用其他方式。
- 性能问题:当监控的文件描述符数目很大时,
示例:
1 | fd_set readfds; |
2. poll
概述:
poll 是对 select 的改进,它解决了 select 文件描述符数量有限的缺点,但依然存在一些性能问题。poll 在功能上类似于 select,但它使用一个结构数组来表示要监控的文件描述符。
工作原理:
- 程序调用
poll并传入一个pollfd结构数组,每个结构体包含文件描述符及其事件类型(POLLIN、POLLOUT、POLLERR等)。 poll阻塞直到一个或多个文件描述符的状态发生变化。poll不会像select那样限制文件描述符数量(受限于系统配置),因此适用于更大的连接数。
优缺点:
- 优点:
- 没有文件描述符数量限制,可以监控更多的文件描述符。
- 适用于中等规模的连接池。
- 缺点:
- 性能问题:虽然没有文件描述符数量限制,但
poll仍然需要在每次调用时遍历所有的文件描述符,因此它的性能在连接数非常大的时候仍然比较低效。 - 内存消耗较高,因为需要为每个文件描述符分配一个
pollfd结构。
- 性能问题:虽然没有文件描述符数量限制,但
示例:
1 | struct pollfd fds[1]; |
3. epoll
概述:
epoll 是 Linux 特有的 I/O 多路复用机制,它是 select 和 poll 的进一步优化,设计用于解决这两者的性能瓶颈。epoll 采用事件驱动方式,它不需要每次调用时遍历所有文件描述符,而是通过内核事件通知机制来高效处理大量并发连接。
工作原理:
- 程序首先使用
epoll_create创建一个epoll实例,该实例与一组文件描述符关联。 - 程序通过
epoll_ctl注册感兴趣的文件描述符及其事件(如可读、可写、异常等)。 - 程序通过
epoll_wait等待文件描述符的事件发生,一旦某个文件描述符有 I/O 操作准备好,epoll_wait就会返回相应的事件。 - 优势:
epoll在事件触发时将只返回有事件的文件描述符,而不是遍历所有文件描述符,极大地提升了性能。
优缺点:
- 优点:
- 性能优越:
epoll通过事件通知机制避免了每次调用时遍历所有文件描述符的开销,性能上比select和poll优越,尤其在大量并发连接时。 - 无需重新扫描:
epoll支持边沿触发(edge-triggered)和水平触发(level-triggered),且每次只会返回有事件的文件描述符。 - 无文件描述符数量限制:
epoll不受文件描述符数量的限制,可以处理大规模的并发连接。
- 性能优越:
- 缺点:
- 只在 Linux 上可用,不适用于其他操作系统。
- 程序的设计可能稍微复杂,因为要处理事件的注册、等待和删除等操作。
示例:
1 | int epfd = epoll_create(1); // 创建 epoll 实例 |
区别
| 特性 | select | poll | epoll |
|---|---|---|---|
| 核心数据结构 | fd_set(位掩码) |
pollfd 结构体数组 |
epoll_event 结构体数组 |
| 文件描述符管理 | 位掩码,固定大小(1024) | 动态数组,无固定大小限制 | 红黑树 + 双向链表,高效管理 |
| 事件存储 | 每次调用需要重新设置 | 每次调用需要重新设置 | 内核维护,事件驱动 |
| 内存开销 | 固定大小,较大 | 动态分配,较大 | 动态分配,较小 |
| 效率 | 低(遍历所有 fd) | 低(遍历所有 fd) | 高(只处理活跃 fd) |
| 时间复杂度 | O(n)(遍历所有 fd) |
O(n)(遍历所有 fd) |
O(log n)(添加/删除)O(k)(事件等待,k 为活跃 fd) |
| 文件描述符数量限制 | 有限制(通常为 1024) | 无硬性限制 | 无硬性限制 |
| 触发模式 | 水平触发(LT) | 水平触发(LT) | 支持水平触发(LT)和边缘触发(ET) |
| 跨平台支持 | 广泛支持 | 广泛支持 | 仅限 Linux |
| 实现复杂度 | 简单 | 简单 | 复杂 |
总结:
-
select:- 优点:实现简单,适合文件描述符数量较少的应用场景。
- 缺点:文件描述符数量有限,性能随着文件描述符的增多急剧下降。每次调用时都需要遍历所有文件描述符,且需要在内核空间和用户空间之间复制文件描述符集合,消耗较大。
-
poll:- 优点:没有文件描述符数量的限制,相比
select,更加灵活,能够支持更多文件描述符。 - 缺点:虽然没有
select的限制,但仍然需要遍历所有文件描述符,性能问题依旧存在,尤其在大量并发连接时。
- 优点:没有文件描述符数量的限制,相比
-
epoll:- 优点:性能极高,特别是在处理大量并发连接时。使用事件驱动机制,仅通知发生事件的文件描述符,避免了轮询和频繁的内存复制。适合高并发、大规模连接的应用。
- 缺点:实现较为复杂,且只在 Linux 上可用。程序设计相对复杂,需要处理事件注册、删除和触发等操作。
一个http服务器的简易实现
采用半同步半反应堆形式实现。
缺点:
- 主线程和工作线程共享请求队列,主线程往请求队列中添加任务,工作线程从请求队列中取出任务是互斥的操作,需要对请求队列进行加锁保护,导致白白浪费CPU时间;
- 每个工作线程在同一时间内只能处理一个客户请求,如果客户较多而工作线程较少,就会导致任务队列中堆积大量的任务对象,此时客户端的响应将越来越慢。而如果通过增加工作线程来解决这个问题,就会因为大量的上下文切换导致消耗大量的CPU时间
主函数:
1 |
|
http消息的读取与分析
http_conn.h
1 |
|
http_conn.cpp
1 |
|
线程池
1 |
|
互斥锁
1 |
|
信号量
1 | class sem{ |
参考文章
-
linux高性能服务器编程-游双