一、前言
本文围绕 C10K 问题展开,讲述 I/O 多路复用:
本文提要: 发现总结C10K 问题 -> 操作系统内核支撑不足 -> 提出 I/O 多路复用。
C10K 问题
背景: 互联网刚开始普及、用户群体几何增长,但单机服务器性能无法被完全充分利用,要投入更多服务器
-
当时服务器采用
PPC(Process Per Connection)模型 :指每次有新的连接就创建一个进程去专门处理这个连接。 -
C10K: 有 1万个连接,服务器就要创建 1万个进程,当时单机操作系统无法承受(出现效率低下、甚至瘫痪)。 -
在这情况下,即使提高服务器配置,并发能力也不能线性提升: 假设原单机能处理 1000 并发,现提高一倍服务器性能,单机处理并发达不到 2000。
归结问题:如何突破单机性能局限
C10K 问题由来: 这些局限和问题最早被 Dan Kegel 进行了归纳和总结,并成系统地分析和提出解决方案,后来这种普遍的网络现象和技术局限都被大家称为 C10K 问题。
-
C10K问题的最大特点: 设计不够良好的程序,其性能和连接数及机器性能的关系往往是非线性的。 -
C10K问题本质上: 是 操作系统 的问题,传统的同步阻塞I/O模型。-
创建进程/线程过多:进程是最昂贵资源。
-
数据频繁拷贝。
-
进程/线程上下文切换消耗大。
-
C10K 问题的解决方案:I/O 多路复用,每个进程/线程能同时处理多个连接。
I/O 多路复用,实现方式有多种:
-
select方式: 有连接请求抵达再检查处理,查询所有文件句柄。 -
poll方式: 设计新的数据结构,解决select的两个问题。 -
epoll方式: 只返回状态变化的文件句柄。
I/O 模型:同步、异步、阻塞、非阻塞
再来回顾下 I/O 模型:
-
阻塞和非阻塞的区别: 线程是否挂起。
-
异步和同步的区别: 主动与被动的通知方式。
UNIX网络编程 中定义 五种 I/O 模型:
- **等待数据: ** 等待数据就绪,数据从网卡写入到内存。
- 将数据从内核复制到用户空间: 将数据从内核空间写到用户空间,这才能给应用使用。
根据上述定义,前4种模型都是同步 I/O 模型:因为真正的 I/O 操作(recvfrom)将阻塞进程。
问题来了:epoll 是异步非阻塞嘛?
结论是:
epoll是同步非阻塞。网上很多说
epoll是异步非阻塞,但我们从UNIX这定义上来看:它就不是。那Linux上加上MMAP呢,这样在『数据从内核拷贝到用户空间』这一步不就省了嘛,那在这步就没有阻塞了,那就能算得上是 异步非阻塞。这个说法有点牵强,定义了模式了,具体实现是在这框定下进行。
Tips:epoll里没有涉及MMAP。
Reactor 是同步非阻塞网络模型。
这个很好理解:因为还有一个
Proactor模式。
Reactor:关注写入事件和读取事件,需要应用程序自己完成读取或者写入数据。Proactor:关注写入完成事件和读取完成事件。
I/O 多路复用:select/poll/epoll
这一趴,只介绍这三种方式:遇到什么问题、解决了什么问题、有什么缺陷。
首先是暴力法:直接循环每个连接(对应 socket)
-
当所有的
socket都有数据时,这种方式是可行的。 -
当应用读取某个
socket时,没有数据,那么整个应用会阻塞在这。
1) select
select的提出是为了解决暴力法的阻塞问题。
select 方案: 在读取文件句柄之前,先检查其状态,ready 就处理。
-
用
fd_set_结构体来存储文件句柄,表示内核同时关注这些句柄。 -
用
FD_ISSET方法来查看哪个文件句柄的状态发生了变化。
#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
// 结构体 (bitmap)
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;
// 返回值就绪描述符的数目
int select(
int max_fd,
fd_set *readset,
fd_set *writeset,
fd_set *exceptset,
struct timeval *timeout // 阻塞时长
)
FD_ZERO(int fd, fd_set* fds) // 清空集合, 置为 0
FD_SET(int fd, fd_set* fds) // 将给定的描述符加入集合, 某位置为 1
FD_ISSET(int fd, fd_set* fds) // 判断指定描述符是否在集合中, 某位是否为 1
FD_CLR(int fd, fd_set* fds) // 将给定的描述符从文件中删除,某位置为 0
缺点有:
- 性能开销大: 每次需要轮询
fd_set每一位。 - 性能开销大: 调用
select时,需要将参数中fd_set从用户空间拷贝到内核空间。 - 同时监听的文件句柄数量少: 受限于
FD_SETSIZE大小,一般 1024,编译内核时就确定了无法更改。
2) poll
poll 主要解决 select 文件句柄数少的问题。
- 解决方法:定义新的结构体。
- 在用户空间通过数组方式传递文件描述符,在内核空间会转为链表方式进行存储,所以没有最大数量的限制。
// 结构体
struct pollfd {
int fd; // 需要监视的文件描述符
short events; // 需要内核监视的事件
short revents; // 实际发生的事件
};
// API
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
缺点:同 select 。
3) epoll
epoll 解决了 select/poll 的缺点,成为了 C10K Killer:
-
解决每次查询文件句柄:
- 使用红黑树存储文件句柄、使用队列存储就绪的文件句柄。
- 通过事件更改文件描述符状态。
-
有红黑树存储,只需要传入一个需要检测的文件句柄,减少了内核和用户空间大量的数据拷贝和内存分配。
select/poll是传入整个文件句柄集合。
// 结构体
struct eventpoll {
...
// 红黑树:监听列表,所有要监听的文件描述符,使用红黑树
struct rb_root rbr;
// 双链表:就绪列表,所有就绪的文件描述符,使用链表
struct list_head rdlist;
...
};
// API
// 1. 创建 eventpoll 对象
int epoll_create(int size);
// 2. epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 3. epoll_wait 检测双链表中是否有就绪的文件描述符,如果有,则返回
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
-
epoll_create(): 创建eventpoll对象,同时返回引用该实例的文件句柄。 -
epoll_ctl(): 会监听文件句柄fd上发生的event事件,op表示对fd执行的操作。- 因为
epoll内核维护一颗红黑树,新增和删除操作,复杂度为O(logn)。
- 因为
-
epoll_wait(): 当有事件发生时,内核通过回调函数将这个文件句柄加入就绪队列中。- 当被调用时,只会返回有事件发生的文件句柄的个数。
Tips: 通过epoll内核源码可知,epoll_wait()没有使用MMAP,__put_user函数就是将数据从内核拷贝到用户空间。
epoll 支持两种事件触发模式:边缘触发(Edge Trigger,ET) 和 水平触发(Level Trigger,LT)
-
边缘触发: 当文件句柄就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知。(重复通知)
-
水平触发: 仅当文件句柄从未就绪变为就绪时,通知一次,之后不会再通知。
区别:边缘触发效率更高,减少了事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。
水平触发、边缘触发的名称来源:数字电路当中的电位水平,高低电平切换瞬间的触发动作叫边缘触发,而处于高电平的触发动作叫做水平触发。
各操作系统提供了功能为了解决 C10K 问题:
-
FreeBSD推出了kqueue,Linux推出了epoll,Windows推出了IOCP,Solaris推出了/dev/poll。 -
但每个接口都有自己的特点,程序移植困难,于是需要对这些接口进行封装,以便使用和移植。
-
libevent库提供统一的API,底层在不同平台上自动选择合适的调用。在以下操作系统中编译通过Linux、BSD、Mac OSX、Solaris和Windows。
C10M问题
既然
C10K问题已经解决了,那就要考虑下C10M问题了。
实现 10M(即1千万)的并发连接挑战意味着什么 :链接
- 1千万的并发连接数;
- 100万个连接/秒:每个连接以这个速率持续约10秒;
- 10GB/秒的连接:快速连接到互联网;
- 1千万个数据包/秒:据估计目前的服务器每秒处理50K数据包,以后会更多;
- 10微秒的延迟:可扩展服务器也许可以处理这个规模(但延迟可能会飙升);
- 10微秒的抖动:限制最大延迟;
- 并发10核技术:软件应支持更多核的服务器(通常情况下,软件能轻松扩展到四核,服务器可以扩展到更多核,因此需要重写软件,以支持更多核的服务器)。
解决思路:关键在于如何将功能逻辑做好恰当的划分
-
数据包直接传递到业务逻辑: 目的减少
Linux内核传输的链路。- 解决方案:网卡问题,使用自己的驱动程序并管理它们,使适配器远离操作系统。
-
多线程的核间绑定: 每个线程绑定到特定处理核心,这样最大化使用核心
Cache、实现无锁设计、避免进程切换消耗等。- 解决方案:核绑定。
-
内存: 采用更适合的页大小,比如从
4K到2M,一定程度上减少地址转换等的性能消耗。- 解决方案:系统启动时,分配大内存、管理大内存页。