操作系统——网络系统(附加webserver项目收获 )
常见OSI七层:物数网传会表应
四层:应用层(负责向用户提供一组应用程序,如HTTP/DNS/TCP);传输层(负责端到端通信,如TCP/UDP);网络层(负责网络包的封装、分片、路由、转发,比如IP/ICMP);网络接口层(负责网络包在物理网络中的传输,比如网络包的封帧、MAC寻址、差错检测,通过网卡传输网络帧)
Linux网络协议栈:应用层到传输层,加了TCP;传输层到网络层,加了IP头;到网络接口层,加了帧头和帧尾。
有MTU最大传输单元,单次传输的最大IP包的大小。
当网络包超过MTU,就会在网络层分片,MTU越小吞吐能力越差。
应用程序——系统调用——lvs——socket——TCP/UDP/ICMP——IP——ARP——MAC——网卡驱动程序——网卡。
应用程序需要通过系统调用,来跟socket层进行数据交互。
网卡负责接收和发送网络包,接收到的时候,会通过DMA技术,把网络包放到环形缓冲区(Ring buffer)
Linux接收网络包的流程:
以前是网卡发中断,中断太多了影响CPU效率。
所以引入了NAPI机制,混合中断和轮询接收网络包,不采用中断的方式读数据,而是首先采用中断唤醒数据接收的服务程序,然后poll的方法来轮询数据。
有网络包到达,网卡发起硬件中断,执行网卡硬件中断处理函数,中断处理函数处理完需要暂时屏蔽中断,然后唤醒软中断来轮询处理数据,直到没有新数据时才恢复中断,这样一次中断处理多个网络包。
第一步就是从环形缓冲区中拷贝数据到内核缓冲区中,作为一个网络包给网络协议栈进行逐层处理。最后,应用程序调用socket接口,从内核的socket接收缓冲区中读取新到来的数据到应用层。
Linux发送网络包的流程:
首先,应用程序调用socket发送数据包的接口,系统调用使得从用户态进入内核态的socket层,将应用层的数据拷贝到socket的发送缓冲区。
接下来,网络协议栈从socket发送缓冲区取出数据包,从上到下传输。最后,触发软中断告诉网卡驱动程序,放到网卡队列,用物理网卡发出去。
硬盘很慢,速度和内存相差十倍以上。优化磁盘速度有很多方案:零拷贝、直接IO、异步IO等,这些优化的目的都是为了提高吞吐量。(操作系统内核中的磁盘高速缓存区可以有效减少磁盘访问次数)
如果没有DMA,磁盘控制器有磁盘控制器缓冲区,CPU有PageCache,read系统调用都要CPU亲自搬送数据。有了DMA,将数据从磁盘控制器缓冲区搬送到系统内核缓冲区的工作就是DMA做,CPU只要调用返回。
传统的文件传输:服务器如果要提供文件传输功能,就要从磁盘上读取,然后通过网络协议传给客户端。
用户态有用户缓冲区,内核态有缓存区和socket缓冲区。然后两边是磁盘文件和网卡。上述就是要从磁盘文件到网卡。
从磁盘文件通过DMA拷贝到内核缓存区;通过CPU拷贝到用户缓冲区;通过CPU拷贝到内核态的socket缓冲区;通过DMA拷贝到网卡。
用户进程从磁盘read,write到网卡发送出去,发生了四次用户态与内核态的上下文切换,还发生了四次数据拷贝,两次CPU两次DMA。
要想提高文件传输的性能,就需要减少用户态和内核态的上下文切换和数据拷贝的次数。
(1)减少上下文切换,就是减少系统调用的次数。读取磁盘数据的时候,之所以发生上下文切换,是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,所以要交给内核,就需要系统调用。
(2)减少数据拷贝,从内核到用户,从用户到内核,这两个步骤是没有必要的。因此,用户的缓冲区存在是没有必要的。
零拷贝:mmap+write、sendfile
mmap()系统调用会直接把内核缓冲区里的数据映射到用户空间(应用进程和操作系统内核共享这个缓冲区),这样内核与用户区就少了一次拷贝操作。
通过使用mmap()代替read(),可以减少一次数据拷贝的过程。但这并不是最理想的零拷贝,因为仍然需要通过CPU把内核缓冲区的数据拷贝到socket缓冲区里,而且仍然需要四次上下文切换,因为系统调用还是两次。
sendfile是专门发送文件的系统调用函数,可以直接把内核缓冲区的数据拷贝到socket缓冲区中,不用再拷贝到用户态。
这样只要一次系统调用(从用户到内核、从内核到用户)这样就只有两次上下文切换,三次数据拷贝。
这还不是真正的零拷贝技术,如果网卡支持SG-DMA技术,我们可以进一步减少通过CPU把内核缓冲区中的数据拷贝到socket缓冲区的过程。
两次上下文切换、两次数据拷贝。总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。Kalfa就用到了零拷贝。
上面提到了内核态的缓冲区就是磁盘高速缓存PageCache。零拷贝技术采用了PageCache技术。因为读写磁盘很慢,所以应该想办法把读写磁盘换成读写内存,于是通过DMA技术把磁盘数据搬到内存里,就可以用读内存替换读磁盘。但是内存空间远比磁盘小,智能拷贝一小部分数据。根据局部性,刚被访问的数据在短时间内再次访问的概率很高。读磁盘的时候,先在PageCache找,如果存在立即返回;如果没有,从磁盘中读取,然后缓存在PageCache里。
但是传输大文件的时候,PageCache不起作用,白白浪费了DMA拷贝的数据,造成性能降低。大文件放进去就没有小文件的空间了,而且大文件也不是热点访问数据。所以大文件传输不用零拷贝。
同步IO如下图。
异步IO如下图。
异步IO没有用到PageCache。绕过PageCache叫做直接IO。使用PageCache叫做缓存IO。
在高并发场景下,针对大文件传输的方式,应该使用直接IO+异步IO的方式来代替零拷贝技术。
IO多路复用技术
socket技术就像在客户端和服务器都开了一个网口,然后用一根网线把两端连接起来。
服务端sbla(socket/bind/listen/accept)
首先调用socket函数,创建指定网络协议、传输协议的socket;接着调用bind函数,给这个socket绑定一个端口和IP地址(当内核收到报文,通过端口号找到应用程序,然后传递数据。一台及其有很多网卡,每个网卡有对应的IP地址,应绑定一个网卡时,内核收到网卡的数据包才会发送给我们);接着调用listen函数进行监听;最后调用accept函数,从内核获得客户端连接,如果没有客户端连接,会阻塞等待客户端到来(所以有多路IO复用技术)
客户端sc(socket/connect)
connect指定服务端的IP和端口号,开始TCP三次握手。
TCP连接的过程中,服务器内核为每一个socket维护了两个队列。一个是还没完全建立连接的队列,叫做TCP半连接队列,服务器处于syn_rcvd。一个是已经建立连接的队列,叫做TCP全连接队列,服务器处于established。
Q:你知道服务器单机理论最大能连接多少客户端吗?
TCP连接是由四元组确定的,本地IP、本地端口、对端IP、对端端口。
服务端的IP和端口是确定的。所以最大TCP连接数=客户端IP数*客户端端口数
对于IPV4,客户端IP数最多2^32,客户端端口数最多2^16,也就是服务器单机最大TCP连接数为2^48
但是实际服务器肯定承载不了那么大的连接数主要受文件描述符(默认1024)和系统内存(TCP连接在内存有对应数据结构)限制。
如果服务器内存2GB,网卡千兆,能支持并发一万请求吗?硬件可行,但重点在于网络IO模型。
为了解决多用户C10K问题,我们分析一下多进程/多线程/IO多路复用三种解决方案。
(1)多进程
父进程主要负责监听socket,子进程主要负责已连接socket。(因为子进程会负责父进程的文件描述符,可以直接使用已连接socket和客户端通信)
(注意:在子进程退出的时候,内核里还会保留该进程的一些信息,也会占用内存,不做好回收功能,就会变成僵尸进程。随着僵尸进程越来越多,就会耗尽我们的系统资源。子进程退出后回收资源,分别是wait和waitpid)
每产生一个进程,都会占据一定的系统资源,而且进程间上下文切换会降低性能。进程间的上下文切换不仅包含虚拟内存、栈、全局变量等用户资源,还包括堆、栈、寄存器等内核空间资源。
(2)多线程
单进程运行多个线程,同进程的线程共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等等。
这些共享资源在上下文切换的时候不需要切换,只需要切换栈、寄存器等不共享的数据。
服务器和客户端TCP完成连接后,通过pthread_create()函数创建线程,然后将已连接socket的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
也得创建销毁线程,麻烦。可以使用线程池。提前创建若干个线程,当新连接建立的时候,将已连接socket放到队列,线程池的线程负责从队列取出已连接socket进程处理。
(3)IO多路复用技术
只使用一个进程来维护多个socket,结合线程池模型。主线程负责监听,子线程负责处理。处理每个请求的事件耗时控制在1毫秒,1秒内就可以处理上千个请求。把时间拉长来看,其实就是在一定时间内一个进程模拟并发时间。
select/poll/epoll就是内核提供给用户态的多路复用系统调用,一个用户进程可以通过这个调用从内核中获取多个事件。
如何获取网络事件?先把所有文件描述符传给内核,然后内核检查是否产生事件的连接,然后在用户态中处理这些连接对应的请求就可以。
select:nfds, readfds, writefds, exceptfds, timeout。
用户——>内核——>用户。内核通过位操作遍历赋值给三个fds数组,传回给用户。
timeout等于0,立即返回。等于null,一直阻塞等待fd就绪才返回。
把已经连接的socket放到文件描述符集合,然后调用该函数将文件描述符集合拷贝到内核,让内核来遍历检查是否有事件产生。当检查到有事件产生,就对这个socket做标志,接着再把整个文件描述符集合拷贝回用户态,然后用户态再遍历处理。
需要两次遍历,两次拷贝。select使用固定bitsmap,表示文件描述符集合,而且所支持的文件描述符的个数有限制,FD_SETSIZE。
poll:fds, nfds, timeout。
fds的每一个都是pollfd结构体,包含文件描述符fd、需要监听的事件类型,真实发生的事件类型(这个要内核要修改)
返回的时候是返回一个fds数组,不是向select那样要返回3个。因为pollfd结构体类型的存在。
不再使用bitsmap来存储文件描述符,而是用动态数组,以链表形式组织,突破了函数设置的文件描述符个数限制,但是还是会受到系统文件描述符限制。
相同点就在于二者都是用线性结构存储进程关注的文件描述符集合,因此都需要两次遍历和两次拷贝。
epoll:
重要场景:一个小区有很多住户,快递员不可能挨家挨户去问寄不寄快递,所以设置了快递箱子(链表)。
epoll_ctl就是记录所有的住户,epoll_wait就是快递员每次去快递箱子看有谁放了快递(比如有客户端要发你好)。
epoll_wait (那栋楼,多大箱子,多长时间)就是箱子通知快递员(用户)的过程。
箱子收到住户003的信封A,过一段时间住户003又要寄信封B和信封C。箱子就会发信号告诉快递员来找,那么箱子到底是发一次短信还是三次短信?(大块数据用边缘触发,小块数据用水平触发)
边缘触发:从没有快递到有快递A是边缘,会先触发一次,然后快递员还没去取,又来了快递B,就不触发了。然后快递员很久才会一次取走两个。(在代码里要自己写循环,直到读到写缓冲区里没数据了)
水平触发:从没有快递到有快递A是边缘,会先触发一次,然后快递员还没去取,又来了快递B,还继续触发,不停通知。
epoll就不一样了。epoll_create在内核创建epoll对象。
epoll在内核里使用红黑树来跟踪文件描述符,把需要监控的所有socket通过epoll_ctl()函数挂到红黑树,因为在内核就不需要进行整个文件描述符集合的拷贝操作,减少了 内核和用户空间大量的数据拷贝和内存分配。内核时间表。
epoll使用事件驱动机制,内核里维护了一个链表来记录就绪事件。当有某个socket发生事件,就会通过回调函数把它加入就绪事件链表中。然后将数据从内核复制到用户空间,然后使用epoll_wait()函数返回有事件发生的文件描述符。
epoll_wait(epfd,用户态数组用于接收已经触发的事件,从网络协议栈最大取多少数据,没有事件到达时最长阻塞时间)
即epoll_wait得参数有epfd, events, maxevent, timeout。成功返回就绪fd个数,失败返回err_no。
将所有就绪时间从内核时间表(由epfd参数指定)中复制到events数组。
内核事件表——>events——>用户
对于events里的就绪事件通知用户的过程,epoll支持两种不同的事件触发模式,分别是边缘触发ET和水平触发LT。
LT:如果那个就绪事件对应的缓冲区还有数据,就是用户还没处理完,就会一直通知用户来处理。
ET:如果那个就绪事件是刚发生,刚从不可读变成可读,或刚从不可写变成可写,那就会通知用户一次。就算用户没处理,后面也不会通知了。因此对于用户来说,读的时候,只要可以读就一直读,否则返回EAGAIN,写也是。
针对ET事件,如果用户线程A先收到了socket事件在处理了,此使socket事件又有事件,被用户线程B接收,就会出现两个线程同时处理一个socket事件。这个问题需要用EPOLLNESHOT事件解决。操作系统最多触发这个socket上注册的一个事件,且只触发一次,除非使用EPOLL_CTL函数重置EPOLLNESHOT事件。所以用户线程A必须重置它。
其中,水平触发就是只要出现满足事件的条件,比如内核有数据需要读,就会一直把这个事件传给用户。当内核通知用户那个文件描述符可读写,接下来还会继续通知。所以用户在收到通知之后,没必要一次执行尽可能多的操作。(可以及时知道有快递)
边缘触发就是只有第一次有数据需要读才会传递这个事件给用户。内核只会通知用户一次,所以用户需要在收到通知后尽可能读写数据,以免错失机会。(可能会忘记了,就会堆积很多快递)
因此,我们会循环从文件描述符读写数据,如果文件描述符是阻塞的,没有数据可读的时候,进程就会阻塞在读写函数,无法向下执行。
因此,ET模式一般和非阻塞IO搭配,程序一直执行IO操作,直到系统调用read或write返回错误,比如EAGAIN或EWOULBLOCK。
ET模式相比于LT模式,可以减少epoll_wait的系统调用次数。因为系统调用也是有上下文切换的开销。
很不错的优缺点:https://blog.csdn.net/weixin_49199646/article/details/112298712
重点:多路复用API返回的事件并不一定是可读写的,如果使用阻塞IO,那么在调用read和write的时候就会发生程序阻塞,因此最好搭配非阻塞IO。
select/poll/epoll是如何获取网络事件的呢?这三个都是系统调用,在获取事件的时候,先把我们要关心的连接传递给内核由内核检测。
如果没有事件发生,线程阻塞在这个系统调用,不用像线程池方案一样轮询调用read操作来判断是否有数据。
如果有事件发生,内核就会返回产生事件的连接,线程就会从阻塞态返回,然后在用户态里处理事件。
先是主线程listen监听事件accept,然后给一些子线程recv和send,然后把任务全部抛到线程池里面去。
Reactor模式的主线程只负责监听文件描述符上面是否有事件发生,如果发生的话就立马通知工作线程,那些(读写数据、接收新连接)、处理客户请求都是在工作线程完成的。(如Redis/Nginx/Netty等)
看epoll_wait函数,阻塞等待网络事件的到达。
Reactor模式也叫Dispatcher模式,IO多路复用监听事件,收到事件之后,根据事件类型分配给某个进程/线程。
Reactor模式主要由Reactor和处理资源池这两个核心部分组成。其中Reactor负责监听和分发事件,事件类型包含连接事件、读写事件;处理资源池负责处理事件。
下面图示单Reactor多线程方案。
方案详情:只有Reactor对象属于主线程。Reactor对象通过select系统调用监听事件,收到事件之后通过dispatch进行分发,具体分发给Acceptor对象还是Handler对象,还要看收到的事件类型。
Reactor是非阻塞同步网络模式,感知的是就绪可读写的事件。应用进程调用read把准备好的socket缓冲区数据读到应用进程内存。(来了事件,操作系统会通知应用进程,让应用进程处理)
Proactor模式的主线程不仅要监听事件,还要负责(处理读写数据、接收新连接),它的工作线程仅负责处理客户请求。
Proactor模式就是服务器的主线程要监听读写事件,调用read_once和http_conn::write完成数据的接收和发送,而子线程只完成对报文的解析和响应。
Proactor是异步网络模式,感知的是已经完成的事件。在发起异步读写请求的时候,需要传入数据缓冲区的地址(用来存放结果数据)等信息,使得系统内核自动把数据的读写工作完成。(来了事件,操作系统会自己处理,处理完再通知应用进程)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下