Day 14

Channing Hsu

linux 内核 vs windows 内核

windows 和 linux 都是常见操作系统。

windows 基本占领了电脑时代的市场,商业上取得了很大成就,但是它并不开源,所以要想接触源码得加入 windows 的开发团队中。

对于服务器使用的操作系统基本上都是 linux,而且内核源码也是开源的,任何人都可以下载,并增加自己的改动或功能,linux 最大的魅力在于,全世界有非常多的技术大佬为它贡献代码。

这两个操作系统各有千秋,不分伯仲。

操作系统核心的东西就是内核,这次就来看看,linux 内核和 windows 内核有什么区别?

内核

什么是内核呢?

计算机是由各种外部硬件设备组成的,比如内存、cpu、硬盘等,如果每个应用都要和这些硬件设备对接通信协议,那这样太累了,所以这个中间人就由内核来负责,让内核作为应用连接硬件设备的桥梁,应用程序只需关心与内核交互,不用关心硬件的细节。

内核

内核有哪些能力呢?

现代操作系统,内核一般会提供 4 个基本能力:

  • 管理进程、线程,决定哪个进程、线程使用 cpu,也就是进程调度的能力;
  • 管理内存,决定内存的分配和回收,也就是内存管理的能力;
  • 管理硬件设备,为进程与硬件设备之间提供通信能力,也就是硬件通信能力;
  • 提供系统调用,如果应用程序要运行更高权限运行的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口。

内核是怎么工作的?

内核具有很高的权限,可以控制 cpu、内存、硬盘等硬件,而应用程序具有的权限很小,因此大多数操作系统,把内存分成了两个区域:

  • 内核空间,这个内存空间只有内核程序可以访问;
  • 用户空间,这个内存空间专门给应用程序使用;

用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间。因此,当程序使用用户空间时,常说该程序在用户态执行,而当程序使内核空间时,程序则在内核态执行。

应用程序如果需要进入内核空间,就需要通过系统调用,下面来看看系统调用的过程:

内核程序执行在内核态,用户程序执行在用户态。当应用程序使用系统调用时,会产生一个中断。发生中断后, cpu 会中断当前在执行的用户程序,转而跳转到中断处理程序,也就是开始执行内核程序。内核处理完后,主动触发中断,把 cpu 执行权限交回给用户程序,回到用户态继续工作。

linux 的设计

linux 的开山始祖是来自一位名叫 linus torvalds 的芬兰小伙子,他在 1991 年用 c 语言写出了第一版的 linux 操作系统,那年他 22 岁。

完成第一版 linux 后,linus torvalds 就在网络上发布了 linux 内核的源代码,每个人都可以免费下载和使用。

linux 内核设计的理念主要有这几个点:

  • multitask,多任务
  • smp,对称多处理
  • elf,可执行文件链接格式
  • monolithic kernel,宏内核
multitask

multitask 的意思是多任务,代表着 linux 是一个多任务的操作系统。

多任务意味着可以有多个任务同时执行,这里的同时可以是并发或并行:

  • 对于单核 cpu 时,可以让每个任务执行一小段时间,时间到就切换另外一个任务,从宏观角度看,一段时间内执行了多个任务,这被称为并发。
  • 对于多核 cpu 时,多个任务可以同时被不同核心的 cpu 同时执行,这被称为并行。
SMP (Symmetric Multi-Processing)

smp 的意思是对称多处理,代表着每个 cpu 的地位是相等的,对资源的使用权限也是相同的,多个 cpu 共享同一个内存,每个 cpu 都可以访问完整的内存和硬件资源。

这个特点决定了 linux 操作系统不会有某个 cpu 单独服务应用程序或内核程序,而是每个程序都可以被分配到任意一个 cpu 上被执行。

ELF (Executable and Linkable Format)

elf 的意思是可执行文件链接格式,它是 linux 操作系统中可执行文件的存储格式,你可以从下图看到它的结构:

ELF 文件格式

elf 把文件分成了一个个分段,每一个段都有自己的作用,具体每个段的作用可以去看《程序员的自我修养——链接、装载和库》这本书。

另外,elf 文件有两种索引,program header table 中记录了运行时所需的段,而 section header table 记录了二进制文件中各个段的首地址

编写的代码,首先通过编译器编译成汇编代码,接着通过汇编器变成目标代码,也就是目标文件,最后通过链接器把多个目标文件以及调用的各种函数库链接起来,形成一个可执行文件,也就是 elf 文件。

执行 elf 文件的时候,会通过装载器把 elf 文件装载到内存里,cpu 读取内存中的指令和数据,于是程序就被执行起来了。

monolithic kernel

monolithic kernel 的意思是宏内核,linux 内核架构就是宏内核,意味着 linux 的内核是一个完整的可执行程序,且拥有最高的权限。

宏内核的特征是系统内核的所有模块,比如进程调度、内存管理、文件系统、设备驱动等,都运行在内核态。

不过,linux 也实现了动态加载内核模块的功能,例如大部分设备驱动是以可加载模块的形式存在的,与内核其他模块解藕,让驱动开发和驱动加载更为方便、灵活。

分别为宏内核、微内核、混合内核的操作系统结构

与宏内核相反的是微内核,微内核架构的内核只保留最基本的能力,比如进程调度、虚拟机内存、中断等,把一些应用放到了用户空间,比如驱动程序、文件系统等。这样服务与服务之间是隔离的,单个服务出现故障或者完全攻击,也不会导致整个操作系统挂掉,提高了操作系统的稳定性和可靠性。

微内核内核功能少,可移植性高,相比宏内核有一点不好的地方在于,由于驱动程序不在内核中,而且驱动程序一般会频繁调用底层能力的,于是驱动和硬件设备交互就需要频繁切换到内核态,这样会带来性能损耗。华为的鸿蒙操作系统的内核架构就是微内核。

还有一种内核叫混合类型内核,它的架构有点像微内核,内核里面会有一个最小版本的内核,然后其他模块会在这个基础上搭建,然后实现的时候会跟宏内核类似,也就是把整个内核做成一个完整的程序,大部分服务都在内核中,这就像是宏内核的方式包裹着一个微内核。

windows 设计

当今 windows 7、windows 10 使用的内核叫 windows nt(new technology)。

下图是 windows nt 的结构图片:

Windows NT 的结构

windows 和 linux 一样,同样支持 multitask 和 smp,但不同的是,window 的内核设计是混合型内核,在上图你可以看到内核中有一个 microkernel 模块,这个就是最小版本的内核,而整个内核实现是一个完整的程序,含有非常多模块。

windows 的可执行文件的格式与 linux 也不同,所以这两个系统的可执行文件是不可以在对方上运行的。

windows 的可执行文件格式叫 pe,称为可移植执行文件,扩展名通常是.exe.dll.sys等。

pe 的结构可以从下图中看到,它与 elf 结构有一点相似。

PE 文件结构

总结

对于内核的架构一般有这三种类型:

  • 宏内核,包含多个模块,整个内核像一个完整的程序;
  • 微内核,有一个最小版本的内核,一些模块和服务则由用户态管理;
  • 混合内核,是宏内核和微内核的结合体,内核中抽象出了微内核的概念,也就是内核中会有一个小型的内核,其他模块就在这个基础上搭建,整个内核是个完整的程序;

linux 的内核设计是采用了宏内核,window 的内核设计则是采用了混合内核。

这两个操作系统的可执行文件格式也不一样, linux 可执行文件格式叫作 elf,windows 可执行文件格式叫作 pe。

1. 常用 Linux 指令

在日常的 Linux 使用和管理中,有很多常用的指令。这些指令帮助用户和管理员高效地执行各种任务。以下是一些常用的 Linux 指令:

  • ls:列出目录内容。

    • ls -l:详细列表形式。
    • ls -a:包括隐藏文件。
  • cd:更改当前目录。

    • cd ..:返回上一级目录。
    • cd ~:进入用户的主目录。
  • cp:复制文件或目录。

    • cp source destination:复制文件。
    • cp -r source_directory destination_directory:递归复制目录。
  • mv:移动或重命名文件或目录。

    • mv old_name new_name:重命名文件。
    • mv file_name directory/:将文件移动到指定目录。
  • rm:删除文件或目录。

    • rm file_name:删除文件。
    • rm -r directory_name:递归删除目录。
  • mkdir:创建新目录。

    • mkdir directory_name:创建一个新目录。
    • mkdir -p parent_directory/sub_directory:递归创建目录。
  • ps:显示当前运行的进程。

    • ps aux:显示所有进程的详细信息。
    • ps -ef:另一种显示进程详细信息的方式。
  • top:实时显示系统进程。

    • h/btop:是 top 的增强版,需要单独安装。
  • chmod:改变文件或目录的权限。

    • chmod 755 file_name:设置权限。
  • chown:改变文件或目录的所有者。

    • chown user:group file_name:更改文件所有者和组。
  • grep:在文件中搜索文本。

    • grep 'pattern' file_name:搜索文件中匹配的行。
  • find:查找文件或目录。

    • find /path -name "file_name":按名称查找文件。

2. 如何查看某个端口有没有被占用

在 Linux 系统中,检查某个端口是否被占用可以使用以下几种方法:

  • 使用 netstat 命令

    1
    netstat -tuln | grep :<port_number>

    例如,检查端口 8080 是否被占用:

    1
    netstat -tuln | grep :8080
  • 使用 ss 命令

    1
    ss -tuln | grep :<port_number>

    例如,检查端口 8080 是否被占用:

    1
    ss -tuln | grep :8080
  • 使用 lsof 命令

    1
    lsof -i:<port_number>

    例如,检查端口 8080 是否被占用:

    1
    lsof -i:8080

如果上述命令有输出,说明端口已经被占用,并且会显示占用该端口的进程信息。如果没有输出,说明端口没有被占用。

3. select、poll、epoll

最基本的 socket 模型

要想客户端服务器在网络中通信,就要使用 socket 编程,在进程间通信可以跨主机。

socket 的中文名叫作插口,事实上,双方要进行网络通信前,各自得创建一个 socket,这相当于客户端和服务器都开了一个口子,双方读取和发送数据的时候,都通过这个“口子”。这样一看,很像弄了一根网线,一头插在客户端,一头插在服务端,然后进行通信。

创建 socket 的时候,可以指定网络层使用的是 ipv4 还是 ipv6,传输层使用的是 tcp 还是 udp。

udp 的 socket 编程相对简单些,这里介绍基于 tcp 的 socket 编程。

服务的 socket 编程过程

服务器的程序要先跑起来,然后等待客户端的连接和数据。 服务端首先调用 socket() 函数,创建网络协议为 ipv4,以及传输协议为 tcp 的 socket ,接着调用 bind() 函数,给这个 socket 绑定一个 ip 地址和端口,绑定这两个的目的是什么?

  • 绑定端口的目的:当内核收到tcp报文,通过tcp头里面的端口号,来找到的应用程序,然后把数据传递给。

  • 绑定ip地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的ip地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给;

绑定完 ip 地址和端口后,就可以调用 listen() 函数进行监听,此时对应 tcp 状态图中的 listen,如果要判定服务器中一个网络程序有没有启动,可以通过 netstat 命令查看对应的端口号是否有被监听

服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。

那客户端是怎么发起连接的呢?

客户端在创建 socket后,调用connect()函数发起连接,该函数的参数要指明服务端的ip地址和端口号,然后万众期待的 tcp 三次握手就开始了。

在 tcp 连接的过程中,服务器的内核实际上为每个socket维护了两个队列:

  • 一个是还没完全建立连接的队列,称为 tcp 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态;

  • 一个是已经建立连接的队列,称为 tcp 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态;

当 tcp 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 tcp 全连接队列里拿出一个已经完成连接的socket返回应用程序,后续数据传输都用这个socket。

注意,监听的 socket 和真正用来传数据的 socket 是两个:

  • 一个叫作监听 socket
  • 一个叫作已连接 socket

连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 read() 和 write() 函数来读写数据。

至此, tcp 协议的 socket 程序的调用过程就结束了,整个过程如下图:

读写 socket 的方式好像读写文件一样。

基于 linux 一切皆文件的理念,在内核中 socket 也是以文件的形式存在的,也是有对应的文件描述符。

如何服务更多的用户?

前面提到的 tcp socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 i/o 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。

可如果服务器只能服务一个客户,那这样就太浪费资源了,于是要改进这个网络 i/o 模型,以支持更多的客户端。

在改进网络 i/o 模型前,先提一个问题,服务器单机理论最大能连接多少个客户端?

相信你知道 tcp 连接是由四元组唯一确认的,这个四元组就是:本机ip, 本机端口, 对端ip, 对端端口

服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 ip 和端口是固定的,于是对于服务端 tcp 连接的四元组只有对端 ip 和端口是会变化的,所以最大 tcp 连接数 = 客户端 ip 数×客户端端口数

对于 ipv4,客户端的 ip 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 tcp 连接数约为 2 的 48 次方

这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:

  • 文件描述符,socket 实际上是一个文件,也就会对应一个文件描述符。在 linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过可以通过 ulimit 增大文件描述符的数目;

  • 系统内存,每个 tcp 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;

那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?

并发 1 万请求,也就是经典的 c10k 问题 ,c 是 client 单词首字母缩写,c10k 就是单机同时处理 1 万个请求的问题。

从硬件资源角度看,对于 2gb 内存千兆网卡的服务器,如果每个请求处理占用不到 200kb 的内存和 100kbit 的网络带宽就可以满足并发 1 万个请求。

不过,要想真正实现 c10k 的服务器,要考虑的地方在于服务器的网络 i/o 模型,效率低的模型,会加重系统开销,从而会离 c10k 的目标越来越远。

多进程模型

基于最原始的阻塞网络 i/o, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。

服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个已连接 socket,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。

这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。

正因为子进程会复制父进程的文件描述符,于是就可以直接使用已连接 socket和客户端通信了,

可以发现,子进程不需要关心监听 socket,只需要关心已连接 socket;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心已连接 socket,只需要关心监听 socket

下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。

另外,当子进程退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽的系统资源。

因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait() 和 waitpid() 函数。

这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。

进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

多线程模型

既然进程间上下文切换的“包袱”很重,那就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型

线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。

当服务器与客户端 tcp 完成连接后,通过 pthread_create() 函数创建线程,然后将已连接 socket的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。

如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。

那么,可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 socket 放入到一个队列里,然后线程池里的线程负责从队列中取出**已连接 socket **进行处理。

需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。

上面基于进程或者线程模型的,其实还是有问题的。新到来一个 tcp 连接,就需要分配一个进程或者线程,那么如果要达到 c10k,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。

i/o 多路复用

既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 socket 呢?答案是有的,那就是 i/o 多路复用技术。

一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 cpu 并发多个进程,所以也叫做时分多路复用。

熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件

select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。

select/poll/epoll 这是三个多路复用接口,都能实现 c10k 吗?接下来,分别说说它们。

select/poll

select 实现多路复用的方式是,将已连接的 socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次遍历文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次拷贝文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 bitsmap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 linux 系统中,由内核中的 fd_setsize 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll 不再用 bitsmap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用线性结构存储进程关注的 socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 socket,时间复杂度为 o(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll

先复习下 epoll 的用法。如下的代码中,先用epoll_create 创建一个 epoll对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 等待数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
int s = socket(af_inet, sock_stream, 0);
bind(s, ...);
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1) {
int n = epoll_wait(...);
for(接收到数据的socket){
//处理
}
}

epoll 通过两个方面,很好解决了 select/poll 的问题。

第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

从下图你可以看到 epoll 相关的接口作用:

epoll 的方式即使监听的 socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 socket 的数目也非常多,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 c10k 问题的利器

边缘触发和水平触发

epoll 支持两种事件触发模式,分别是边缘触发edge-triggered,et)和 水平触发(level-triggered,lt)。

  • 使用边缘触发模式时,当被监控的 socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此程序要保证一次性将内核缓冲区的数据读取完;

  • 使用水平触发模式时,当被监控的 socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉有数据需要读取;

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

如果使用边缘触发模式,i/o 事件发生时只会通知一次,而且不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 i/o 搭配使用,程序会一直执行 i/o 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 eagain 或 ewouldblock

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

另外,使用 i/o 多路复用时,最好搭配非阻塞 i/o 一起使用,linux 手册关于 select 的内容中有如下说明:

under linux, select() may report a socket file descriptor as “ready for reading”, while nevertheless a subsequent read blocks. this could for example happen when data has arrived but upon examination has wrong checksum and is discarded. there may be other circumstances in which a file descriptor is spuriously reported as ready. thus it may be safer to use o_nonblock on sockets that should not block.

在 Linux 下,select() 可能会报告一个套接字文件描述符为“已准备好读取”,但随后进行的读取操作可能会阻塞。这种情况可能发生在数据已经到达,但在检查时发现数据校验和错误并被丢弃的情况下。还有其他情况可能导致文件描述符被虚假地报告为已准备好。因此,对于不应该阻塞的套接字,使用 O_NONBLOCK 可能会更安全。

简单点理解,就是多路复用 api 返回的事件并不一定可读写的,如果使用阻塞 i/o, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 i/o,以便应对极少数的特殊情况。

总结

最基础的 tcp 的 socket 编程,它是阻塞 i/o 模型,基本上只能一对一通信,那为了服务更多的客户端,需要改进网络 i/o 模型。

比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。

为了解决上面这个问题,就出现了 i/o 的多路复用,可以只在一个进程里处理多个文件的 i/o,linux 下有三种提供 i/o 多路复用的 api,分别是:select、poll、epoll。

  • select

    • 最早的 I/O 多路复用机制,出现在 4.2BSD 中。
    • 使用一个固定大小的数组来存储文件描述符,最大数量受到 FD_SETSIZE(通常是 1024)的限制。
    • 每次调用 select 都需要重新设置文件描述符集合,并在内核和用户空间之间复制数据,效率较低。
  • poll

    • 出现在 System V.4 中,克服了 select 的一些限制。
    • 使用一个 pollfd 结构体数组,没有最大文件描述符数量的限制。
    • 不需要每次都重新设置文件描述符集合,但仍然需要在内核和用户空间之间复制数据。
  • epoll

    • Linux 内核特有的 I/O 多路复用机制,效率更高。
    • 包括三个系统调用:epoll_createepoll_ctlepoll_wait
    • 使用一个文件描述符管理内核中的事件表,避免了每次调用都复制数据。
    • 支持水平触发和边缘触发模式,边缘触发模式下事件通知更高效。
    • 更适合大规模并发连接,如高并发的网络服务器。

select 和 poll 并没有本质区别,它们内部都是使用线性结构来存储进程关注的 socket 集合。

在使用的时候,首先需要把关注的 socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 socket 集合,找到对应的 socket,并设置其状态为可读/可写,然后把整个 socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 socket 集合找到可读/可写的 socket,然后对其处理。

很明显发现,selectpoll 的缺陷在于,当客户端越多,也就是 socket 集合越大,socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对c10k

epoll是解决lc10k问题的利器,通过两个方面解决了select/poll的问题。

  • epoll 在内核里使用红黑树来关注进程所有待检测的 socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 ,通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。

  • epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,只将有事件发生的socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 socket ),大大提高了检测的效率。

而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。

评论