0、引言
本篇博客将从socket模型为起点,引入IO多路复用的学习。
1、Socket模型
1.1、Socket的诞生
Socket的诞生背景:
Socket最早出现在20实际80年代的Unix操作系统中,当时计算机和网络技术逐步发展,分布式计算开始流行,操作系统需要提供一种标准化的网络通信方式来连接不同的设备。这种通信方式应当对开发者头透明,避免让他们直接处理底层的网络协议。
让我们来举一个具体的例子来说明为什么需要Socket!设想在早期,发送邮件是一个非常麻烦的事情,为了将邮件发送到预期的地方,我们需要了解邮政编码的规则、不同地区的路线、运送流程等,还可能得亲自跑去各个邮政节点去完成投递。这个过程非常繁琐,于是在后来,邮局系统提供了一种标准化的邮件传送方式,人们只需要将信件交给邮局就可以了,邮局会处理所有的细节确保信件达到目标地点。
在这个例子中,邮局就像我们的Socket,它允许操作者通过简单的操作就能确保将数据传输到指定的客户端中。
我们再来详细地认识它,Socket——它是操作系统提供的“一套“应用编程接口(API),能够实现跨网络、跨设备的数据交换。Socket使得程序不需要直接操控底层网络协议,而是通过一套统一的接口来收发数据包,方便了网络应用开发。
1.2、Socket的使用
接下来我们来了解Socket的具体使用流程。Socket = IP地址 + 端口号:
- Socket的组成:每一个Socket由IP地址和端口号组成,IP地址用于指定计算机的地址,端口号表示计算机上的特定服务。
- Socket的通信方式:基于Socket的通信方式,可以分为面向连接的(TCP Socket)和无连接的(UDP Socket)。TCP Socket用于提供稳定的数据传输,适用于文件传输、网页请求等需要数据完整性的应用,而UDP Socket适用于要求较低的数据完整性但是需要快速传输的场景,如视频流。
我们来具体的了解TCP Socket的编程。现在我们有一个服务端和一个客户端,我们需要服务端先能跑起来,然后接收客户端的连接和数据,先来看看服务端的Socket编程过程。
我们在服务端,首先要调用socket()
函数,获得一个fd句柄表示创建的Socket,并为其使用bind()
绑定一个IP地址和端口。
fd句柄是文件描述符(File Description)的简称,在操作系统中,它是一个整数,用来表示和管理进程打开的文件或网络连接。文件描述符是操作系统为每个打开的文件、Socket连接等资源分配的唯一标识符,用于在应用程序和操作系统之间抽象资源的访问。
然后,我们就可以对这个fd句柄调用listen()
函数进行监听,此时就对应TCP状态图中的listen。当服务端进入监听状态后,通过调用accept()
函数,来从内核获取客户端的连接,如果没有客户端发起连接,就会阻塞地等待客户端的连接。
那么我们如何使用客户端发起连接呢?客户端在创建好Socket之后,就可以调用connect()
发起连接了,连接的参数即为服务端的IP地址和端口,接着就开始进行TCP三次握手了。
这里补充一张TCP三次握手的过程图示。
三次握手可以理解为:
- 第一次握手告诉服务器,我要发起连接了。
- 第二次握手表示服务器告诉客户端,表示同意你的连接了,并为其分配资源。
- 第三次握手表示客户端收到了服务器的同意了。
在TCP连接的过程中,服务器的内核实际上为每个Socket维护了两个队列:
- 一个是「还没完全建立」连接的队列,这个队列会在完成二次握手的时候建立,称为TCP半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于
syn_rcvd
的状态; - 一个是「已经建立」连接的队列,称为TCP全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于
established
状态;
⚠️当TCP全连接队不为空后,服务端的accept()
函数,就会从内核中的TCP全连接队列里拿出一个已经完成连接的Socket返回应用程序,后续数据的传输都用这个Socket。
值得注意的是,监听的Socket和真正用来传输数据的Socket是两个
- 一个叫做监听Socket;
- 一个叫做已连接Socket;
在后续,双方就可以通过read()
和write()
函数来读写数据了。至此,TCP协议的Socket程序的调用过程就结束了,过程用下图表示。
1.3、内核的数据结构
在每一个进程中,都有一个数据结构task_struct
,该结构体里有一个指向「文件描述符数组」的成员指针。这个数组列出了这个进程打开的所有的文件。数组的下标是文件描述符即fd句柄,是一个整数,而数组的内容就是文件描述符所对应的那个文件的指针,指向内核中所有打开的文件的列表。
注意,fd_array的指针指向的,是内核文件列表中的指向一个叫struct file
结构体的指针,而struct file
用于表示一个打开的文件的结构体。每个打开的文件都会有一个struct file
实例,用于维护与该文件相关的状态信息。
为什么内核文件列表要这样子设计?
内核文件表的设计是为了有效管理文件资源,文件资源是对文件元数据的引用,便于多个进程共享同一个文件而不重复加载文件内容。
在Linux内核中,有一个叫做sock
的结构,是用于实现Socket的核心数据结构,它负责维护与Socket相关的所有信息和状态,涵盖了网络协议栈的各个层次。sock的主要功能包括:
- 存储与网络连接相关的信息,如源地址、目标地址、协议类型等
- 提供数据包的接收和发送功能,包括队列管理、缓冲区管理
- 网络协议之间的交互,例如TCP、UDP、ICMP等
到这里,可以用一张图来表示Socket和Sock的关系。
我们可以把Socket看作是一个内核提供的接口库,包含着多个进行网络通信的方法例如bind()
,listen()
,read()
,write()
等,它介于用户空间和linux内核之间,而真正进行操作网络通信的数据的是内核中的sock结构,我们只需要调用socket提供的方法,就能去对sock操作。
在文件系统中,有一个叫做inode
的通用结构,用于描述文件的元数据。在Linux和Unix系统中,一切皆文件的设计理念下,inode
是文件系统中管理文件的基础结构,适用于普通文件、目录、符号链接、设备文件等。
每一个文件都会有一个inode,Socket文件的inode指向了内核的sock结构,sock结构体里面有两个队列,分别是发送队列和接受队列,这两个队列里面保存的是一个个sk_buff
结构体,用链表的组织形式穿起来。
sk_buff可以表示各个层的数据包,在应用层叫做data,在TCP层叫做segment,在IP层叫做packet,在数据链路层叫做frame。
疑问:为什么全部数据包都只用一个结构体来描述呢?
协议栈采用的是分层结构,上层向下层传递数据时需要增加包头,下层向上层传递数据时又需要去掉包头,如果每一层都用一个不同的结构体,那么层之间传递就需要发生多次数据拷贝,降低系统效率。
于是为了不让层级之间传递数据时不发生数据拷贝,我们只用sk_buff一个结构体来描述所有的网络包,它是如何做到的呢?是通过调整sk_buff中data
的指针。从下图我们可以看到,发送报文时data指针的移动过程。
2、I/O多路复用
2.1、如何服务更多的用户?
在上文提及到的TCP socket调用流程是最简单、最基本的,他基本只能一对一通信,因为使用的是同步阻塞的方式。当服务端还没有处理完一个客户端的网络I/O时,或者读写操作发生阻塞的时候,其他客户端是无法与服务端连接的。
如果我们的服务器只能服务一个客户,那样资源就太浪费了,于是我们想要改进这个网络I/O模型,以支持更多的客户端。
在改进网络I/O模型之前,我们先来提出一个问题:服务器单机理论最大能连接多少个客户端?
我们知道TCP连接是由四元组唯一确定的:本机IP,本机端口,对端IP,对端端口。
服务器作为服务方,通常会在本地固定监听一个端口等待客户端的连接,所以服务器的IP和端口是固定的。于是最大TCP连接数=客户端IP数*客户端端口数
对于IPv4,客户端的IP数最多为2的32次方,客户端的端口数最多为2的16次方,也就是服务端单机最大TCP连接数为2的48次方。
这个理论值相当“丰满”,但是服务器肯定承担不了那么大的连接数,主要会受两个方面的限制:
- 文件描述符,Socket实际上是一个文件,也就会对应一个文件描述符,在Linux下,单个进程打开的文件描述符数是有限制的,没有经过修改过的值一般都是1024,不过我们可以通过ulimit增大文件描述符的数目;
- 系统内存,每个TCP连接在内核都有对应的数据结构,意味着每个连接是会占用内存的;
如果服务器的内存只有2GB,网卡是千兆的,能支持并发1万请求吗?
并发一万请求,也叫做C10K问题。从硬件资源角度看,如果每个请求处理占用不到200KB内存和100Kbit的网络带宽就可以满足C10K。
不过要想实现真正的C10K的服务器,要考虑的地方在于服务器的网络I/O模型,效率低的模型会加重系统开销,从而会离C10K的目标越来越远。
2.2、多进程模型
基于最原始的阻塞网络I/O,我们可以使用多进程模型,即为每一个客户端分配一个进程来处理请求。
当服务器通过accept()函数返回一个「已连接」Socket的时候,就可以通过fork()
函数创建一个子进程,实际上就是把父进程相关的东西都复制一份。
对于父进程,只需要关心的是客户端的连接,而具体的读写交给了子进程去完成。
这种模型在应对100个客户端是可行的,但是当客户端数量高达一万的时候肯定会撑不住,因为每生产一个进程,必定会占据一定的系统资源,而且进程间的上下文切换的“包袱”是很重的,性能一定会大打折扣。
2.3、多线程模型
尽然在进程间上下文切换带来的负担比较大,那么我们就转而使用轻量级的模型来应对多用户的请求——多线程模型。
当服务器与客户端TCP完成连接后,通过pthread_create()
函数创建线程,然后将「已连接Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统销毁线程,虽说线程的上下文切换开销不大,但是频繁的创建和销毁也是不必要的性能损耗。
所以我们可以引用线程池的方法,来避免线程频繁创建和销毁带来的损耗。线程池就是提前创建若干个线程,当有新连接建立需要消耗一个线程的时候,就从线程池中取出一个来使用,运行完了再放回去。
还会存在着一个问题,新到来一个TCP连接就意味着要分配一个线程,那么如果并发量来到C10K,意味着一台机器要维护1万个线程,操作系统也是扛不住的。
2.4、I/O多路复用
既然为每个请求分配一个进程/线程的方式不合适,那是否可以只用一个进程来维护多个Socket呢?这就要提及到I/O多路复用技术了。
一个进程虽然任意时刻只能处理一个请求,但是处理每个请求的事件耗时如果能控制在1毫秒以内,这样1秒内就可以处理上千个请求,把时间拉长来看多个请求复用了一个进程,这就是多路复用,这种思想类似于一个CPU并发多个进程,所以也叫做时分多路复用。
线程池是一种多路复用技术吗?
并不完全是。多路复用通常是指在一个线程中同时管理多个I/O操作,而线程池更专注于管理和复用线程资源,以处理并发任务。线程池的复用机制与多路复用的思想类似,但线程池并不是在一个线程内处理多个任务,而是让多个线程并行处理不同任务。
select/poll/epoll调用中,进程可以通过该系统调用函数从内核中获取多个事件。
select/poll/epoll是如何获取网络事件的呢?在获取事件时,先把所有连接传给内核,再由内核返回产生了事件的连接,然后在用户态中处理这些连接对应的请求即可。
记下来我们来分别讨论着三个接口能否实现C10K。
2.5、select/poll
什么是select?
select系统调用是一个用于I/O多路复用的接口,允许程序监视多个文件描述符,以便它们变得可读、可写或出现异常时进行处理。它提供了一种机制,使得一个线程或进程可以有效地管理多个I/O操作,而不需要为每个I/O操作都创建一个进程或进程。
select实现多路复用的方式是,将已连接的Socket都放到一个文件描述符集合中,然后调用select函数将该集合拷贝到内核里面,然后让内核来检查是否有网络事件的发生。检查的方式就是遍历,当检查到有事件后,就将Socket标记为可读或者可写,接着再把整个fd集合拷贝回用户态里,然后用户态还需要再通过遍历的方式找到可读或可写的Socket,然后再进行处理。
所以对于select这个方式,需要进行2次「遍历」文件描述符集合,一次是在内核态里,一次是在用户态里,而且还会发生2次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select使用固定长度的BitsMap表示文件描述符集合,而且所支持的文件秒舒服的个数是有限制的,在Linux系统中,由内核中的FD_SETSIZE限制,默认最大值为1024,只能监听0~1023的文件描述符。
poll不再用BitsMap来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了select的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是poll和select并没有太大的本质区别,都是使用「线性结构」存储进程关注的Socket集合,因此都需要遍历文件描述符集合来找到可读或可写的Socket,时间复杂度为O(n),而且需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
2.6、epoll
epoll是Linux操作系统提供的高效I/O多路复用机制,用于替代了select和poll,适合高并发的场景。与select和poll的线性扫描方式不同,epoll通过事件驱动的机制来高效管理大量文件描述符,极大地减少了在高并发场景下的CPU和内存开销。
使用epoll的方法如下:首先使用epoll_create
创建一个epoll
对象epfd
,再通过epoll_ctl
将需要监视的socket添加到epfd
中,最后调用epoll_wait
等待数据。
int epfd = epoll_create(...);
epoll_ctl(epfd, ...);
while(1){
int n = epoll_wait(...);
for(){...}
}
epoll的底层主要的核心结构有两方面:
- 第一,epoll在内核使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的socket通过
epoll_ctl()
函数加入内核中的红黑树里,内核会维护这一个红黑树,相比与select/poll,可以减少了内核和用户空间的大量数据拷贝和内存分配。 - 第二,epoll使用事件驱动机制,内核里维护了一个链表来记录就绪事件,当某个socket发生事件的时候,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,不需要像select/poll那样轮询扫描整个socket集合,提高了检测的效率。
epoll的方式即使监听的Socket数量越多的时候,效率也不会大幅度降低,能够同时监听的Socket数量为系统进程打开的最大文件描述符个数,epoll被称为解决C10K问题的利器。
值得注意的是,epoll_wait的调用返回,是将内核中就绪态事件的Socket拷贝到用户态中。
2.6.1、epoll的边缘触发和水平触发
epoll支持两种事件触发模式,一种是边缘触发(edge-triggered, ET)和水平触发(level-triggered, LT)。
- 使用边缘触发时,当被监控的Socket描述符上有可读事件发生时,服务器只会从epoll_wait中苏醒一次,即使进程没有调用read函数从内核读取数据,也依然苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 使用水平触发时,当被监控的Socket上有可读事件发生时,服务器不断地从epoll_wait中苏醒,直到内核缓冲区数据被read函数读完才结束,目的是告诉我们有数据需要读取;
具体的差别就是,例如,在 TCP 连接中,假设应用程序接收缓冲区有 4 KB 数据,但 read
调用只读取了 2 KB,那么使用边缘触发模式下,剩下的 2 KB 将不会再触发新的可读事件,导致数据丢失。
我们用实际例子抽象举例,可以想象一个门上有一个人不断按门铃,直到你出来处理。即使你一直没开门,门铃还会响,提醒你门外有客人(也就是数据等待处理),这就是水平触发模式;想象按门铃的人只按一次,不会再重复。这意味着你必须及时出来处理(即读取数据),否则按门铃的人不会再按第二次,即使门外还有其他客人,这就是边缘触发模式。
使用边缘触发模式的时候,由于I/O事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应该尽可能的读写数据,以免错失读写的机会。因此,我们会循环地从fd读写数据,那么如果fd是阻塞的,没有数据可以读写时,进程会阻塞在读写函数那里,程序就没有办法继续往下执行。所以边缘触发模式一般和非阻塞I/O搭配使用,程序会一直执行I/O操作,直到系统调用(如read
和write
)返回错误,错误类型为EAGIN
或EWOULDBLOCK
。
一般来说,边缘触发的效率会更高,它避免了重复事件的处理,提高了效率,但是需要更高的编程要求。
3、总结
Socket是操作系统内核提供的一个接口库,它提供了多个接口用于我们进行网络传输,使用Socket我们需要直到对方的地址,由IP地址和端口号组成。
最基础的TCP的Socket编程是阻塞I/O模型,基本上只能进行一对一通信,为了服务更多的客户端,我们需要改进网络I/O模型。
传统的改进方式是使用多进程/线程模型,对于每一个客户端连接都分配一个进程/线程去处理,但是当客户端增大到一定数量例如10000个的时候,进程/线程的调度、上下文切换以及占用的内存会显著提高,一个服务器难以支撑。
为了解决多进程/线程模型带来的弊端,于是出现了I/O多路复用,可以只在一个进程里处理多个文件的I/O。Linux下有三种提供I/O多路复用的API,分别是select、poll、epoll。
select和poll没有本质区别,内部都是使用「线性结构」来存储进程关注的Socket集合。
使用的时候,需要先将所有关注的Socket集合通过select/poll系统调用拷贝到内核态,由内核来检查事件,当网络事件发生时,内核需要遍历所有关注的Socket集合,找到对应的Socket并设置状态为可读/可写,然后再把Socket集合拷贝到用户态,用户态还需要进行一次遍历找到需要处理的Socket再进行处理。
select和poll的缺点体现在需要多次的重复拷贝,并且遍历开销大。
epoll是解决C10K问题的利器,通过两个方面来解决了select和poll的问题
- epoll在内核使用「红黑树」数据结构来存储需要关注的Socket,增删改的时间复杂度一般为O(logn),不需要再频繁的在用户态和内核态之间拷贝Socket,减少了数据拷贝和内存分配。
- epoll使用事件驱动机制,内核中维护了一个「链表」来记录就绪事件,只将有事件的Socket集合传递给应用程序,不需要再像select/poll那样轮询扫描,提高了检测效率。
epoll支持边缘触发和水平触发的方式,一般而言边缘触发的效率会更高,但是更加需要代码的严谨性。
4、参考博客
本篇博客个人学习、总结、摘抄至:小林coding
标签:Socket,多路复用,epoll,线程,内核,IO,连接,客户端 From: https://www.cnblogs.com/MelonTe/p/18526283