文章目录
阻塞 I/O 模型-基于 TCP 的 Socket 编程原理
一、Socket 编程概述
Socket 是进程间通信的一种特殊方式,能够实现跨主机间的通信。在网络通信前,客户端和服务器都需要各自创建一个 Socket,它就如同一个“口子”,数据的读取和发送都通过这个“口子”进行,类似于一根网线连接客户端和服务端。创建 Socket 时,可以指定网络层的 IPv4 或 IPv6 协议,以及传输层的 TCP 或 UDP 协议,本文重点介绍基于 TCP 的 Socket 编程。
二、服务端 Socket 编程流程
-
创建 Socket:
服务端调用socket()函数,创建一个网络协议为 IPv4、传输协议为 TCP 的 Socket。这是进行网络通信的基础,就像打开了一个用于通信的“窗口”。 -
绑定 IP 地址和端口:
接着调用bind()函数,为这个 Socket 绑定一个 IP 地址和端口。- 绑定端口的目的:当内核收到 TCP 报文时,会通过 TCP 头中的端口号找到对应的应用程序,从而将数据传递给该程序。端口号就像是一个门牌号,帮助内核找到正确的“房间”(应用程序)。
- 绑定 IP 地址的目的:一台机器可能有多个网卡,每个网卡都有对应的 IP 地址。绑定特定的 IP 地址后,只有当内核收到来自该网卡上的包时,才会将包发送给对应的应用程序。这就好比在一栋有多个入口(网卡)的建筑中,明确指定从哪个入口接收包裹(数据)。
-
监听端口:
绑定完 IP 地址和端口后,调用listen()函数进行监听,此时对应 TCP 状态图中的listen状态。可以通过netstat命令查看对应的端口号是否处于被监听状态,以此来判断服务器中的网络程序是否已经启动。 -
接受客户端连接:
服务端进入监听状态后,通过调用accept()函数从内核获取客户端的连接。如果没有客户端连接请求,该函数会阻塞等待,直到有客户端连接到来。
三、客户端 Socket 编程流程
-
创建 Socket:
客户端同样先创建一个 Socket,这是进行通信的前提条件。 -
发起连接:
客户端调用connect()函数发起连接,该函数的参数需要指明服务端的 IP 地址和端口号。此时,万众期待的 TCP 三次握手开始。
四、TCP 连接过程中的队列机制
在 TCP 连接过程中,服务器的内核为每个 Socket 维护了两个队列:
- TCP 半连接队列:这个队列中存储的是还没有完成三次握手的连接,此时服务端处于
syn-rcvd状态。可以理解为连接请求已经发出,但还在等待对方的最终确认。 - TCP 全连接队列:该队列中存储的是已经完成三次握手的连接,此时服务端处于
established状态。当 TCP 全连接队列不为空时,服务端的accept()函数会从内核的 TCP 全连接队列中取出一个已经完成连接的 Socket 返回给应用程序,后续的数据传输都将使用这个 Socket。
需要注意的是,监听的 Socket 和真正用来传输数据的 Socket 是不同的,一个称为监听 Socket,用于等待和接受客户端的连接请求;另一个称为已连接 Socket,用于实际的数据传输。
五、数据传输
连接建立后,客户端和服务端就可以开始相互传输数据了。双方都可以通过 read() 和 write() 函数来进行数据的读取和写入操作,实现数据的交互。
I/O 多路复用(I/O Multiplexing)详解
I/O 多路复用是一种高效的 I/O 事件管理机制,允许单个线程同时监控多个文件描述符(如 Socket、管道等),并在它们可读、可写或发生异常时进行通知。它是 Redis、Nginx、Node.js 等高并发系统的核心实现技术之一。
为什么需要 I/O 多路复用?
在传统 I/O 模型中:
- 阻塞 I/O(Blocking I/O):线程会一直等待数据就绪,无法处理其他请求。
- 非阻塞 I/O(Non-blocking I/O):线程需要轮询检查数据是否就绪,浪费 CPU。
- 多线程/多进程:虽然可以并发处理,但线程切换和资源竞争带来额外开销。
I/O 多路复用的优势:
✅ 单线程管理多个 I/O 流,避免多线程竞争。
✅ 减少系统调用(相比非阻塞轮询)。
✅ 高并发支持,适用于 C10K(万级连接)甚至更高。
核心思想
- 注册监听:应用程序告诉内核它关心的文件描述符(FD)和事件(读、写、异常)。
- 事件通知:内核在 FD 就绪时通知应用程序,避免轮询。
三种主要实现方式
| 方式 | 特点 | 适用场景 |
|---|---|---|
select | 1. 跨平台支持(Linux/Windows) 2. FD 数量有限(默认 1024) 3. 线性扫描 FD 集合 | 旧系统兼容 |
poll | 1. 无 FD 数量限制(链表存储) 2. 仍需要遍历所有 FD | 比 select 稍高效 |
epoll | 1. Linux 特有,高性能 2. 事件驱动,仅返回就绪的 FD 3. 支持水平触发(LT)和边缘触发(ET) | 高并发服务器(Redis/Nginx) |
kqueue | FreeBSD/macOS 的 epoll 替代方案,类似机制 | BSD 系系统 |
select 多路复用机制
1. 工作原理
select 会将已连接的 Socket 对应的文件描述符存于一个文件描述符集合中,调用 select 函数时,该集合会从用户态拷贝到内核态。内核通过遍历文件描述符集合来检查是否有网络事件产生,若有事件发生,会将对应的 Socket 标记为可读或可写。之后,整个文件描述符集合会被拷贝回用户态,用户态再遍历集合找到可读或可写的 Socket 并处理。
2. 局限性
- 文件描述符数量限制:使用固定长度的
BitsMap存储文件描述符集合,在 Linux 系统中,受FD_SETSIZE限制,默认最多只能监听 0 - 1023 的文件描述符。 - 性能损耗大:需要进行两次遍历文件描述符集合(内核态和用户态各一次),并且发生两次文件描述符集合的拷贝(用户态到内核态,内核态到用户态)。随着并发数增加,性能损耗呈指数级增长。
poll 多路复用机制
1. 工作原理
poll 把进程关注的 Socket 以动态数组(链表形式)存储,突破了 select 中文件描述符个数的固定限制。不过它和 select 一样,都使用线性结构存储 Socket 集合。当需要检查可读或可写的 Socket 时,也是通过遍历文件描述符集合的方式,时间复杂度为
O
(
n
)
O(n)
O(n)。同时,同样需要在用户态和内核态之间拷贝文件描述符集合。
2. 优缺点
- 优点:突破了
select文件描述符个数的固定限制。 - 缺点:和
select本质类似,随着并发数增加,性能损耗依然较大,因为仍需遍历和拷贝操作。
epoll 多路复用机制
1. 红黑树存储待检测文件描述符
epoll在内核中使用红黑树来跟踪进程所有待检测的文件描述符。通过epoll_ctl()函数可以将需要监控的Socket加入内核的红黑树中。红黑树是一种高效的数据结构,其增删改操作的时间复杂度一般为 O ( l o g n ) O(logn) O(logn)。- 相比
select和poll,epoll由于在内核维护了红黑树来保存所有待检测的Socket,所以每次操作时只需传入一个待检测的Socket,减少了内核和用户空间大量的数据拷贝和内存分配。
2. 事件驱动机制
epoll使用事件驱动机制,内核维护了一个链表来记录就绪事件。当某个Socket有事件发生时,内核会通过回调函数将其加入到这个就绪事件列表中。- 当用户调用
epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,无需像select和poll那样轮询扫描整个Socket集合,大大提高了检测效率。
对比总结
| 特性 | select | poll | epoll |
|---|---|---|---|
| 文件描述符存储 | 固定长度的 BitsMap(有个数限制) | 动态数组(链表形式,突破个数限制) | 内核红黑树 |
| 查找方式 | 遍历文件描述符集合(时间复杂度 O ( n ) O(n) O(n)) | 遍历文件描述符集合(时间复杂度 O ( n ) O(n) O(n)) | 基于事件驱动,无需遍历整个集合 |
| 内核用户态交互 | 需在两者间拷贝整个描述符集合 | 需在两者间拷贝整个描述符集合 | 只需传入单个待检测描述符,减少拷贝 |
| 并发性能 | 随并发数增加性能损耗指数级增长 | 随并发数增加性能损耗较大 | 高并发场景下性能表现优异 |
| 局限性 | 文件描述符个数受限(默认 1024) | 仍受系统文件描述符总数限制 | 实现相对复杂 |
综上所述,select 和 poll 实现简单,但在高并发场景下性能较差;epoll 虽然实现相对复杂,但凭借红黑树和事件驱动机制,在高并发场景下能显著提升性能。
总结
- I/O 多路复用的核心:单线程管理多个 I/O 流,依赖内核事件通知机制。
epoll是最佳实践:高性能、低开销,适合 Linux 高并发服务器。- Redis/Nginx 的成功依赖于此:单线程 + 多路复用 = 高并发 + 低延迟。

753

被折叠的 条评论
为什么被折叠?



