Day 14
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 文件有两种索引,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 和 linux 一样,同样支持 multitask 和 smp,但不同的是,window 的内核设计是混合型内核,在上图你可以看到内核中有一个 microkernel 模块,这个就是最小版本的内核,而整个内核实现是一个完整的程序,含有非常多模块。
windows 的可执行文件的格式与 linux 也不同,所以这两个系统的可执行文件是不可以在对方上运行的。
windows 的可执行文件格式叫 pe,称为可移植执行文件,扩展名通常是.exe
、.dll
、.sys
等。
pe 的结构可以从下图中看到,它与 elf 结构有一点相似。
总结
对于内核的架构一般有这三种类型:
- 宏内核,包含多个模块,整个内核像一个完整的程序;
- 微内核,有一个最小版本的内核,一些模块和服务则由用户态管理;
- 混合内核,是宏内核和微内核的结合体,内核中抽象出了微内核的概念,也就是内核中会有一个小型的内核,其他模块就在这个基础上搭建,整个内核是个完整的程序;
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 | int s = socket(af_inet, sock_stream, 0); |
epoll 通过两个方面,很好解决了 select/poll 的问题。
第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl()
函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是
第二点, 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
结构体数组,没有最大文件描述符数量的限制。 - 不需要每次都重新设置文件描述符集合,但仍然需要在内核和用户空间之间复制数据。
- 出现在 System V.4 中,克服了
-
epoll:
- Linux 内核特有的 I/O 多路复用机制,效率更高。
- 包括三个系统调用:
epoll_create
、epoll_ctl
和epoll_wait
。 - 使用一个文件描述符管理内核中的事件表,避免了每次调用都复制数据。
- 支持水平触发和边缘触发模式,边缘触发模式下事件通知更高效。
- 更适合大规模并发连接,如高并发的网络服务器。
select 和 poll 并没有本质区别,它们内部都是使用线性结构来存储进程关注的 socket 集合。
在使用的时候,首先需要把关注的 socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 socket 集合,找到对应的 socket,并设置其状态为可读/可写,然后把整个 socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 socket 集合找到可读/可写的 socket,然后对其处理。
很明显发现,select
和 poll
的缺陷在于,当客户端越多,也就是 socket
集合越大,socket
集合的遍历和拷贝会带来很大的开销,因此也很难应对c10k
。
epoll
是解决lc10k
问题的利器,通过两个方面解决了select/poll
的问题。
-
epoll 在内核里使用红黑树来关注进程所有待检测的 socket,红黑树是个高效的数据结构,增删改一般时间复杂度是
,通过对这棵黑红树的管理,不需要像 select/poll
在每次操作时都传入整个socket
集合,减少了内核和用户空间大量的数据拷贝和内存分配。 -
epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,只将有事件发生的socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 socket ),大大提高了检测的效率。
而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。