关于网络编程中的一些小问题研究总结

前言

原本打算继续往下总结第五个并发模型来着,但是觉得有些不妥。一是因为真正进入到Reactor并发模式,理解的还不是很深,总觉的缺点什么;二是自己原本实现的示例代码写的也不是很好,不是很成一定的style。 所以想着先一般总结一些前面遇到的比较碎的知识点,然后一边研究【1】中的网络库实现,模仿一个简单的网络库,倒是在继续往下总结Reactor部分。

这里先探究几个小问题:
(1)、关于“惊群问题”。
(2)、关于socket网络编程中的reuseport。
(3)、关于select、poll、epoll的原理探究。
(4)、关于socket api中,内核的工作与用户空间的工作讨论。
(5)、…

下面一个一个进行探究。


一、关于“惊群问题”

  惊群问题在以前的博客中也有提及,主要的原因就是多个进程或者线程在阻塞等待一个共同的资源,当资源可以获得之时,这些进程或者线程被一起唤醒所造成的的资源浪费、性能下降的现象(主要的浪费在于操作系统把所有的进程唤醒之后,大部分进程无法获得资源,只能重新回到阻塞队列当中,这其中会有上下文的白白的切换)。

对于惊群现象来说,它是一个广义的概念,在多线程、多进程编程中很多情况下都会遇到。 特别的在网络编程中,常见的有两种惊群现象,一个是accept惊群,另一个是IO复用的惊群现象(selec,poll,epoll等)。 下面这篇文章讲解的很好,我就不班门弄斧了。^ _ ^


Linux惊群效应详解(最详细的了吧)

这里说一个我的小疑问:在IO复用的惊群问题底层实现中,是如何做到唤醒“部分”阻塞的进程的?【1】中的说法是事件在一部分进程唤醒之前已经处理了,所以内核不会在唤醒剩下的进程。 enen…, 说得通,但是按照我目前对操作系统“唤醒”的理解,其是在条件达成之时,将阻塞在条件上的所有进程都移到就绪队列上(如果只阻塞在一个条件之上的时候)。
  非常希望有了解的小伙伴能给我讲讲.

二、关于socket网络编程中的reuseport

对于上面介绍的"accept惊群"现象,linux内核在3.9版本之后推出了另外一种解决方式-REUSEPORT。

“accept”惊群是多个进程或者线程同时阻塞在同一个监听套接字上(listenFd),当有连接到来之时,内核会唤醒阻塞的进程或线程们。linux 2.6版内核之后,内核会唤醒其中的某一个进程或线程。 本质上来说,这是现代内核帮我们解决的一个方案,避免了加锁带来的消耗。

这里说的reuseport, 是内核以另外一种形式帮我们解决“accept惊群问题”的方案(可能还有其他的作用,这里暂且不讨论了)。 socket 如果设置成了reuseport的选项,那么它就可以和其他的socket(它们也需要设置为reuseport选项) 共同绑定到同一个端口,具体来说是同一个addr+port 。 再具体点说,内核会把来自客户端的请求(请求的目的是addr+port)均衡的发到这些socketX 上。 如下示意图所示。

在这里插入图片描述
上图摘自【2】,详细的说明可以参照【2】。


enen… 基本内容介绍完了,但是稍微深挖一点的话,这儿还有几点不太理解的:
(1)、为什么内核已经解决了“accept”惊群的问题,还需要弄一个reuseport呢?
(2)、内核的这两种对“惊群”问题的解决方案有什么区别?

这些留待以后慢慢研究吧。

三、关于select、poll、epoll的原理探究

IO复用机制算是一个比较“牛”的机制,因为它给了并发编程提供了一种新的思路。

long long ago, 实现的服务器端模型大都是迭代式服务器,一个服务完成了,才可以进行下一个服务。

然后是多进程并发服务器,但是多进程服务器有两个基本的问题,一个是开销比较大(包括生成、销毁、切换等),另一个是通信问题,进程间的通信有点麻烦。

线程模型出来之后,有些并发服务器开始使用线程来代替进程,使用多线程模型来进行服务器端并发模型的开发,每个线程监控一个服务(客户端连接或者其他形式的IO)。多线程模型也有其固有的问题,一个是受限于并发连接数受限于系统的最大线程数量(一般内核都有最大的线程数量限制),另一个是线程之间通过共享进程空间的方式来通信(共享数据)会有互斥、同步等问题(包括编程难度和加锁、解锁时的消耗)(一旦陷入到死锁问题,很容易就让人崩溃,^ _ ^|||)。

后来,不知哪位大佬就创造性的开发出了IO复用机制,在一个进程或者线程中可以同时阻塞等待多个IO(多个客户端连接或者其他形式的IO请求),当其中的某一个或者某几个IO有事件到来之时(可读、可写或者异常)才会返回通知上层的用户。 以这种方式从某种程度上大大提高了服务器端的并发量。

所以,后来以IO复用为基础,现在比较流行的IO并发模型框架就是Reactor模型 。


上面说了IO复用的由来和基本概念,下面主要介总结介绍一下linux系统下select、poll、和epoll的基本实现原理。

3.1 关于select 的基本原理

按照我比较抽象、比较大的方面理解:select的实现主要就是以下几个步骤:

=》select 进入

(1)、用户提供所关注的fd_set(其中包括每个fd所感兴趣的事件:可读、可写、异常等)
(2)、内核将fd_set从用户空间拷贝到内核空间(为什么需要拷贝请参照【4】)
(3)、遍历fd_set,将当前进程(current)挂到不同fd的等待队列中去。
(4)、如果没有感兴趣的fd事件发生,则进程处于阻塞状态(这里不考虑schdule_timeout 超时和信号到来被唤醒)。当感兴趣的事件到来之时,会唤醒阻塞的进程。
(5)、被唤醒的进程重新遍历fd_set中的事件,将准备好的fd收集起来,最后拷贝到用户空间(返回的事整个文件描述符数组,里面并不是都是就绪的IO,需要用户代码自行判断)

=》select 返回

以上是我比较粗矿的理解,有很多措辞和说法可能不是很准确,请见谅。


比较详细的讨论可以参照【3】。

为了帮助理解,我从【5】那儿也盗了张图 ^ _ ^|||。

在这里插入图片描述



下面说说select这种机制的一些缺点:

  • 由于使用位图来存储fd 以及一些内核本身的设置,select 最多支持1024个fd.
  • 每次调用select进入内核或者从内核返回之时需要进行用户空间和内核空间的fd_set的传递,如果fd数量比较大的话,开销也会不小。
  • 在select 内部实现时,有一个大循环( for(; ; ) ),每次唤醒之后都需要遍历所有的fd_set集合(线性扫描,复杂度是O(N)),开销应该也是“杠杠的”。
  • 从本质上来说,select返回到用户空间的是整个文件描述符数组,需要用户代码遍历数组以找出哪些文件描述符上有IO事件,


3.2 关于poll的基本原理

对于poll来说,其基本原理和上面的select差不多,区别就是poll机制的fd描述采用了链表的形式(select是bitmap)这样支持的最大文件描述符数量就不止1024个了。


3.3 关于epoll的基本原理


对于epoll来说,它基本解决了上面select的几个缺点。 它的基本原理,简要来说可以用下面这张图来说明(⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄ 也是从【5】中“偷” 过来的)。

在这里插入图片描述

具体的详细介绍请参阅【3】、【5】【6】。 下面主要以epoll如何解决select上面的几个缺点为切入点进行总结。

(1)、对于select 支持的fd数量有限制。
  epoll中使用红黑树(如上图中红色圈起来的部分)来存储感兴趣的IO,理论上fd的数量没有限制(可能受限于硬件条件如内存等,这里不考虑)


(2)、对于每次调用select 进入内核和从内核返回时需要进行fd_set的传递拷贝。
   epoll机制中含有一个epoll_ctl 系统调用,只有在第一次插入fd(本质上插入的是感兴趣的事件,在内核中被封装成了epitem)时,会进行拷贝,以后的每一次epoll_wait( epoll机制的阻塞调用),都不会重新将fd从用户空间拷贝到内核去。
   同时,内核和用户空间还共享了一片内存,用来存放epoll_wait返回的时候所带回来的有IO到来的fd_set。 这样也就避免了从内核空间向用户空间复制数据的开销


(3)、关于select 每次有感兴趣的事件到来时(进程被唤醒)都需要进行重新进行线性扫描的“尴尬”。
  epoll机制中有两个比较重要的数据结构,一个是红黑树(如上图红色圈起来的部分),它用来存放用户注册的感兴趣的事件。 另一个是就绪队列(上图中蓝色圈起来的部分),这个是存放感兴趣的事件中已经就绪的IO, ( epoll_wait( )就是从这个队列中读取数据的。 )

  那为什么epoll不需要每次的线性扫描呢? 因为在有IO事件到来之时,其注册的回调函数(在epoll_ctl()中注册)会把事件添加到就绪队列中,这样epoll_wait()就不用向select 内部的do_select()函数那样,还需要重新遍历所有的文件描述符。 因此达到了O(1)的复杂度。

(4)、关于select最终返回的事真个文件描述符数组的问题。
   在epoll的实现中,因为有了就绪队列(不是进程就绪的那个队列啊)这个数据结构,所有epoll_wait( ) 本质上就是等待这个就绪队列里面有数据,然后从这个队列里扒拉数据给用户空间(其实是共享内存啦),最终给的数据都是“货真价实”的IO请求,绝不让用户代码再费力判断。


关于IO复用的原理总结部分,就先到这儿吧,原来还打算正儿八经的看看源码来着,但是到时正儿八经的被源码“抽”了个耳光子。 有点小难,虽然大体也看了一点,但很多细节的地方还是没怎么看懂。自然,也有一些源码的精华部分,没有体会到:比如说,“有人说,epoll机制中最重要的是回调机制,类似于事件处理那种 是? ” 胃不好,一口吃不成个胖子,这些还需要以后再慢慢体会啊。 ^ _ ^

四、关于socket api中,内核的工作与用户空间的工作讨论。

最后这一部分主要来扯扯内核工作和用户空间的工作。

一般来说,对于非内核开发者(操作系统)来说,我们大都算是app 开发者(只是细分的层不一样,框架类系统类可能更靠appd的底层,java web业务应用可能更靠app的上层)。我们最终都需要调用内核给我们提供的各种服务(可以简要理解为系统调用),所以本质上说内核和应用在一起构成了我们一整套“应用”(可以从用户空间和内核空间来理解)。

在我们调用系统api时,比如说网络编程中经常遇到的socket api,内核在底部帮我们做了很多事,比如说在connect/accept 时,内核中的网络协议栈帮我们自动完成了三次握手等连接过程。 write(或者read)时,上层用户只是把想要传输的数据写到了内核缓冲区(确切的说这一步也是内核帮我们完成的,我们只是简单的调用了write 这个系统调用),然后由内核帮我们按照TCP的协议把数据传输到远端。

设想这样一个场景,有两个服务器进程A和B, 它们以相同的并发模型方式向外提供服务。A进程连接了10个客户,B进程连接了100个客户,那么问题来了,一般普通情况下(不要较真,这里说的是一般普通的情况,A和B有相同的环境,CPU还算比较空闲,…),在给定的一段时间内,A和B谁占用的内核时间长,或者说内核作为一种服务(连接阻塞、读写阻塞等)为谁服务的时间长?

用脚趾头都能想到,肯定是B啦,在一定时间范围内,内核作为一个勤劳的“奶妈”,它给B喂的奶肯定较多些。但是这里要稍微注意一下,如何A和B如果分到的时间片相同的话,B进程内核占用的时间较多,那么其用户的时间片占据的就少些。同理,A进程内核占用的时间片较少,它就有较多的时间片执行用户态的任务。 (当然,总体来说,B进程应该还占得一些异步中断的便宜,因为异步中断时不考虑进程的时间片)

以上是我根据自己的理解总结出来的,如果有什么不妥的地方,还希望各位小伙伴可以指正。^ _ ^|||。


参考

【1】、Linux惊群效应详解(最详细的了吧)
【2】、不可不知的socket和TCP连接过程
【3】、select/poll/epoll原理探究及总结
【4】、epoll原理剖析#2: select & poll
【5】、select、poll、epoll的原理与区别
【6】、epoll原理剖析#3: epoll

猜你喜欢

转载自blog.csdn.net/plm199513100/article/details/112789845
今日推荐