线程与网络编程
1、传统模型
传统模型,主要采用阻塞IO+单独开启线程处理连接的方式,基本上是所有操作系统都支持的一种方式。
主要通过一个线程不断接受连接,对于每个连接单独开启线程进行处理。
1、存在的问题
上述方案存在的问题主要要从操作系统的系统调用和线程创建切换流程来看
1、系统调用
现代操作系统运行在保护模式下,即分为内核态和用户态,为的是防止用户态程序破坏操作系统的完整性。
系统调用时一种用户态的应用程序访问操作系统内核资源(如线程资源)的一种方式,开销包括:
1、应用层数据拷贝到内核态
2、用户态现场相关信息保护,因为系统调用需要返回
3、状态的切换
4、安全检查
等操作。
2、线程创建和切换
线程属于操作系统资源,应用程序需要通过系统调用创建线程;
创建线程需要:
1、系统调用开销
2、操作系统内核创建对应的内核线程对象存放线程信息
3、线程的栈内存分配
大概流程如下:
1、某个线程通过系统调用创建线程
2、操作系统内核创建初始化线程所需的内核对象
3、操作系统将创建好的线程挂到可被调度的线程集合中
4、创建线程系统调用返回
5、时间中断到来,操作系统的调度程序启动
6、调度程序查看当前正在执行线程分配的时间片是否已经用完
7、当前执行线程的时间片已经用完,则从可被调度线程集合中选择一个线程进行执行
8、继续第五步
操作系统的调度远比上述复杂,这里只是简单理解。
3、单线程方案
从上面的线程创建和切换,我们可以看出采用创建线程处理每个连接带来的问题有:
1、线程内存的开销
2、线程创建系统调用的开销
3、线程数量多导致线程切换的开销
那么可不可以使用单个线程处理多个连接?解决方案是使用非阻塞IO,非阻塞IO是所有操作系统都支持的操作,通过设置socket为非阻塞,则后续对socket进行的读写操作会立刻返回。
那么单线程非阻塞IO会存在什么问题呢?这个我们可以直接从为什么要有阻塞IO和阻塞IO的实现来看。
4、阻塞与非阻塞IO
当线程调用等待操作时,如通过read系统调用等待网络数据到来。我们有两种方案选择:
1、频繁调用,系统调用判断是否有数据之后立刻返回。应用程序一直调用等待数据到来。
2、调用之后阻塞,等待真正有数据到来时再让系统调用返回。
方案1比较好理解,但是问题在于系统调用时存在开销的。
1、线程阻塞
所以有了方案2阻塞的实现,阻塞操作结合了操作系统的系统调用和线程切换。
从上面的线程切换流程可以知道,操作系统的调度程序是从一个可调度线程集合中选取线程进行执行。
阻塞操作的本质就是如果
1、线程调用一个阻塞操作
2、如果系统调用在内核态没有等待到数据到来
2、把当前调用阻塞操作的线程从可调度线程集合中取出,放到一个不可被调度线程集合中
这意味着当前调用阻塞操作的线程不会被调度程序调度,线程不会得到执行,不会占用CPU执行时间。
2、恢复执行
有阻塞就会有恢复,以网络read操作阻塞为例,我们进行网络编程的时候需要创建socket,socket网络资源也是属于操作系统资源,需要通过系统调用进行创建,并且会在内核层创建内核对象socks;
我们线程调用阻塞操作时,会将当前阻塞线程挂在内核层socks对象下。
当网络数据到来时,触发网络中断,网络中断处理时会使用内核线程(软中断)去处理网络请求,数据进入网络协议栈,经过层层解包,找到数据包对应的socks内核对象,唤醒socks内核对象下的阻塞线程,即将该线程从不可被调度线程集合转移到可被调度的线程集合中去。
再等待下一次时间中断,通过调度程序调度执行该线程,读取到来的网络数据,系统调用返回。
3、区别
阻塞IO和非阻塞IO的区别在于,是否要占用CPU执行时间去做空等待;
对于是否选用非阻塞IO,取决于连接是否经常有数据到来,如果连接长时间处于空闲状态,可以采用阻塞IO;
如果连接长时间存在数据交换,可以使用非阻塞IO,让CPU空转,避免因阻塞导致线程切换时带来的开销。
2、总结
其实我们从单线程还是多线程处理连接、阻塞还是非阻塞两个维度看,其实编程模型有四种:
阻塞 | 非阻塞 | |
---|---|---|
单线程 | / | |
多线程 |
上面的单线程方案还有两点需要注意的就是:
1、单线程阻塞在传统模型内是不存在,即阻塞IO时无法使用单个线程同时处理多个连接。
2、单线程非阻塞是循环对每个连接进行一次非阻塞IO系统调用
从上面,我们可以看出,传统的模型问题所在。
1、采用多线程阻塞/非阻塞IO的方式:创建线程需要内存和系统调用开销;
2、采用单线程的方式:
-
不确定是否有数据到来,需要频繁对每个连接进行非阻塞IO系统调用;
-
单线程下只能一次系统调用处理一个连接
2、传统模型的优化
从上述的问题,我们其实可以看出有两种解决方案,一种是减少创建线程的开销,一种是让单线程处理多个连接,并且减少系统调用的次数。分别对应编程语言和操作系统提供的实现。
对于问题1,我们可以降低线程创建的内存和创建线程进行的系统调用,让线程更加的轻量化,典型实现是Go语言的用户态线程goroutine实现,不存在系统调用且创建时占用的内存低。
对于问题2,主要是线程、系统调用、连接这三者的关系。在传统模型下,是单线程单次系统调用只能处理单个连接,我们需要提供一种方式,单线程单次系统调用能处理多个连接。
首先来看下对于问题2,操作系统对于单线程单系统调用处理多个连接的实现
1、操作系统
以Linux为例,Linux下最新的针对传统模型优化的网络编程模型是epoll。
1、Linux
现在我们只考虑上面传统模型单线程下如何处理多个连接,存在的问题:
1、单线程无法阻塞IO处理多个连接
2、单线程非阻塞IO处理多个连接需要进行频繁的系统调用
第一个问题,为什么单线程无法阻塞处理多个连接,从阻塞IO的流程中我们可以看出,因为当前线程进行阻塞IO系统调用操作单个连接时,线程被移出可被调度线程集合,并且挂在了内核socks上,等到数据到来时,再从socks上找到该线程将其唤醒,从这里看,实际上,线程和连接是一对一的关系。
那么Linux是如何解决这个问题的呢?引用一句名言
All problems in computer science can be solved by another level of indirection
计算机科学中的所有问题都可以通过增加一个间接层来解决
Linux在线程和socks之间加了一个epoll,即一个线程对应一个epoll,一个epoll对应多个socks。epoll属于Linux操作系统的内核资源,需要通过系统调用进行创建。
在加入epoll后,新的阻塞IO调用流程就变成了:
1、线程在epoll上阻塞等待,即挂在epoll上
2、多个socks挂在一个epoll上
3、单个socks有数据到来时,找到对应的epoll,然后唤醒挂在epoll下阻塞的线程
这样模型就从之前的单个线程挂在单个socks上,变成了单个线程挂在单个epoll下,epoll管理多个socks。
并且对epoll的epoll_wait非阻塞系统调用时,会返回存在数据到来的socket对象集合,这样即解决了问题1也解决了问题2。
还有一点是关于使用epoll时阻塞IO和非阻塞IO的选择,epoll管理socks时在非阻塞IO下是可以通过一次系统调用告知应用程序有哪些socks存在数据到来,所以在使用阻塞IO的情况下,可能会频繁的调用epoll_wait进行等待epoll对象。
想象一个场景,单个线程在epoll上阻塞等待多个socks连接的数据到来,某个时刻多个socks同时数据到来,而被唤醒的线程在等待的系统调用返回结果上显示只有单个socks数据到来,则处理完该socks数据后,继续等待epoll,产生额外的开销。
所以在使用epoll时,尽量使用带有超时时间的非阻塞IO,一次epoll等待处理多个socks事件结果,避免多次调用epoll_wait。
2、编程语言
1、Go语言
从上面可以看出,对于传统模型的问题2,Linux操作系统给出的解决方案。
那么go语言是如何解决这个问题的呢?只是提供一个用户态线程让线程资源消耗没那么大就行了吗?实现起来还是比较复杂的。
从上面的阻塞和非阻塞IO调用原理,我们可以看出,其实当使用阻塞IO的时候,线程和操作系统的调度是牢牢绑在一起的,即线程通过系统调用进入内核,然后内核中的实际阻塞IO函数将当前线程给移出了可被调度的线程集合了。用户层是没有办法干涉这一操作的。
从上面的线程切换原理可以看出,操作系统的打断执行机制是依赖于时间中断实现的,操作系统提供给用户层的接口只有当前线程通过系统调用(yield)去放弃CPU执行。
类比操作系统的线程调度实现,想要实现一个用户态的线程调度,我们应该有一个调度器,去调度这些用户态线程在内核线程上的执行,我们还需要能够打断这些用户态线程的执行,并且在用户态线程中的操作不能阻塞。
这里变成两个问题:
1、如何在调度器打断用户态线程执行
2、如何让用户态线程执行IO操作时不会被阻塞,即当前线程不会被移出可被调度线程集合
对于问题1,用户态线程必须显示的去调用调度函数,判断自己是否应该让出cpu,放弃执行。以实现调度器打断用户态线程执行,这被称为协作式(cooperative)调度。
这里有一个很有意思的问题用户态线程在什么时候执行调度函数,中断执行。有两种方案,
1、Go语言是编译型语言,源代码会编译成本地二进制文件,那么在编译成二进制时,可以在goroutine函数中插入调用调度函数
2、在调用golang内部函数的时候执行调度
第一种方案不太好确定插入点,并且会带来很大的性能开销,golang使用的是第二种,调用内部函数的时候,进入调度函数。
这里实现方案依据golang版本不同而不同,以实际的源码实现为准。还有一个问题就是,如果用户态线程在执行的时候一直没有执行到调度函数,就会独占线程,导致其它的用户态线程饿死得不到CPU执行,这也是语言层面上需要解决的一个问题。
对于问题2,需要封装用户接口,让所有的IO操作都使用非阻塞IO,并且在一个线程中统一的轮训事件,事件到来时去让对应的用户态线程进行执行。
即Go语言对于传统的网络模型的优化是:使用轻量级的用户态线程goroutine,并且封装IO操作使用基于多路复用的非阻塞IO。
可以说,对于传统网络编程模型的两个问题,Go语言都解决了。
所以Go语言的优势在于简单,高效,在其它的编程语言还在依赖于使用第三方的框架来实现高效且复杂的网络编程的时候,Go语言已经从语言层面上内置了高效的实现。
3、事件驱动模型
主流的网络编程模型就是基于非阻塞IO多路复用的事件驱动循环模型。
Linux下即单线程通过epoll的相关系统调用监听多个socket的事件,当有事件到来的时候,进行处理。
事件的处理一般不在事件循环线程中执行,因为都在单个线程中处理对于CPU的利用率不高,而是通过线程池的方式去处理事件。
事件驱动模型并不能解决线程创建的内存资源、线程创建销毁和系统调用的开销,线程池技术是一个折中的解决方案。
1、Java和Go对比
以Web服务器为例,比较一下Java和Go
首先是Web服务器本身,Java使用的是Tomcat,Go是语言自带的特性。
Tomcat使用的是非阻塞IO多路复用+线程池技术,即一个accept线程专门接受连接,一个poll线程从连接中取出事件,然后将事件给线程池进行处理。
这里二者并没有太多优劣之分,主要是体现在对连接事件的处理上,Java的连接处理是使用的线程池,这里存在一个问题
线程池最大线程数是固定的,这是由于语言使用的内核线程并且线程不是一种廉价的系统资源,但是如果说线程池中所有正在执行的线程执行阻塞操作,那么意味着线程池中没有线程会被CPU执行,并且线程池中排队的任务也不会得到执行。
举个例子,我们通过Java和Go实现一个Web服务器,暴露一个接口,接口执行sleep 2s,Java的线程池最大线程数量设置500。
如果1000个线程同时访问,则Java平均前500个线程访问时间为2s,后500个线程访问时间为4s,因为在线程池中后500个线程会等待前面的500个线程执行完毕;而Go所有线程的访问时间都是大约2s,因为Go从语言层面上使用的都是非阻塞IO,调度器会在调用IO操作时切换用户态线程执行。
这里只是举的一个极端例子,在正常web服务中,并不会这样调用sleep。那么在Web请求中会执行哪些操作呢。常见的主要有:
1、网络操作(tcp、http、rpc、数据库)
2、文件访问
3、纯CPU计算操作
对于第三种纯CPU的计算,Go性能或许会不如Java,因为计算操作都是要占用CPU执行时间的,Go的协调式调度只会加慢这个过程。
对于一和二,都可以算作是IO操作,对于Go而言,IO操作在语言层面提供的实现全是基于非阻塞IO,对于tcp、http、rpc、数据库操作的框架都是基于Go的语言框架,所以都是异步访问,所以不存在问题。
而对于Java而言,语言是提供传统模型和基于多路复用的非阻塞IO两种实现即BIO和NIO,可以自由选择,而且Java拥有广大的社区,很多组件和框架是可替换的,需要依赖于他们各自的实现,并且需要进行整合,这个是很麻烦的事。
如果上面的例子sleep 2s变为数据库sql执行2s,http或rpc服务调用2s,情况是不是一样的呢。而且就算是rpc、http或者jdbc是一个异步调用的框架,是不是还是需要和web服务器的线程池等实现进行整合交互呢。
Java社区应该也有在做这样的事,整合原有的不如重新开发一套新的,但是Go是从语言层面官方进行的支持,还是有所差别。
粗浅理解,欢迎指正。
标签:调度,调用,epoll,编程,网络,阻塞,线程,IO From: https://www.cnblogs.com/zanpocc/p/16715570.html