【Golang】并发模型小结

小结:各种语言都支持传统的多线程模型实现并发python比较特殊因为有GIL无法真正并发、java/nodejs的reactor事件模型因为callback hell会导致业务逻辑分散和不易维护、scala通过akka事件库和actor模型实现了高并发和代码逻辑的连贯性、golang的创新在于使用mpg调度器实现用户代码逻辑上同步阻塞但实际底层实现了非阻塞调用把复杂性隐藏在语言层面、erlang玩得更绝自己把操作系统的进程管理实现了一遍可以秒级启动几百万个轻量级进程。谁优谁劣不好说,都有自己适合的场景和相对存在的问题,计算密集还是IO密集、易用性还是灵活性、平台还是业务、外部因素与内部现实等,看我们追求什么权衡什么

 

/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 

Golang(Go)作为近几年兴起的语言,其本身的特点使其兼顾了性能与开发效率,加上学习的门槛比较低,很快便普及开来。众所周知,Go在处理并发上有着天生的优势,使用Go开发的UI层,支撑了多盟RTB日均五十亿时延要求在100ms内的广告请求。这篇文章就来简单介绍下这种处理并发的优势是如何实现的。

 

并发

Concurrency is a property of systems in which several computations are executing simultaneously, and potentially interacting with each other,维基百科上这样定义并发。多进程在同一个核内分时调度执行或者在多核下并行执行,都可以被称为并发。配合各种粒度的锁使用的多线程是我们最常用的并发模型,其它还有诸如函数式并发编程模型、Erlang中使用的Actor模型以及Golang中使用的CSP模型等。

 

进程,线程,协程

我们先来看看操作系统对并发的支持,在linux系统中,进程是对执行的程序的抽象,这层抽象主要用来描述执行代码镜像、虚拟内存空间、各种内核资源等等;而线程则是操作系统对最小执行单元的抽象,这层抽象主要描述cpu内各种寄存器等的状态。进程在是线程之上的构建的抽象屏障,是可执行代码和所操作数据的集合。   

 

内核负责对进程、内核线程进行调度,系统会把不同的进程运行在各个CPU上来提升CPU的利用率,当一个进程阻塞时,CPU会被调度执行其他的进程。调度的目的是提高系统的在单位时间内执行的任务数,让进程占用的资源更加合理,系统对于进程和线程的调度是无差别的。其中,线程由于共享了虚拟内存空间等资源,线程在进行上下文切换时也不需要进行诸如保存、装载各种状态数据等资源,刷新TLB(x86)等操作,所以线程调度会比进程调度高效很多。  

  

除了操作系统级别提供的线程和进程外,还有一种被称为Green Threads的用户态线程的概念。Green Threads可以理解为在应用程序级别实现了类似操作系统线程这样的概念,是在操作系统之上构建的并发对象。那么用户态线程可以用来做什么呢?最显而易见的是:即使是在不支持多进程(线程)的操作系统上,我们依然可以通过在语言层面上实现Green Threads来支持并发逻辑;其次是Green Threads的创建和调度都在用户空间里进行,不需要进行用户态与内核态的切换;最重要的是操作系统提供的线程是一个强大而复杂的对象,对于大多数简单的应用程序来说,过于“重”了,而Green Threads则可以被构造的非常简单,调度策略也实现的更加简单,占用更少的资源。虽然Green Threads不能自动实现被多个处理器调用,但可以通过实现达成这个目的,Go里的Goroutine就是一个很好的例子。

 

Goroutine

首先Goroutine通过调度,将多个用户态线程绑定在在若干内核线程上来实现,并使用了Work stealing调度算法来达到多核处理的效果。

如下图所示:

 

 

 

Go的runtime定义了M、P、G三个角色,分别代表POSIX Threads,Processor和Goroutine。P可以理解为执行上下文,也就是context,我们在Golang 1.5之前的版本需要设置的GOMAXPROCS数就是指P的数量。P负责完成对G和M的调度,我们可以把M理解为操作系统资源的抽象,是真正的执行体;把G理解为用户程序要执行的代码的抽象,是执行代码和数据的集合。P用执行体M来执行G,并且维护了一个deque来存放可执行的G,当前G执行结束,M就空闲了下来,P就可以从deque的顶部取出下一个G在M上继续执行。   

 

那么如果G中执行了带阻塞的系统调用,调度会有什么样的变化呢?如下图所示:

 

 

 

当M去执行该系统调用时线程会阻塞并被操作系统挂起,这个时候P会把当前的G留在原来的M中处理,然后从deque里取出下一个G并创建一个新的M对象来执行它。被丢弃的G-M对完成系统调用变成可执行状态时,又会在合适的时机被重新调度执行。这也就是为什么即使GOMAXPROCS被设置成1,Goroutine还是能用到多核处理。

 

而当一个P对象将维护的deque里的G全部执行完之后,可以从别的P的deque底部拿到一半的G放入自己的deque中执行,这也就是为什么叫做Work stealing算法,这也是Goroutine为何高效的一个很大原因。

 

G比系统线程要简单许多,相对于在内核态进行context switch,G的切换代价低了很多,调度策略非常简单,毕竟操作系统要为各种复杂的场景提供完整的解决方案,而通常我们应用程序层面解决的问题都相对简单。

 

非阻塞IO与IO多路复用

现在我们知道协程的创建和上线文切换都非常“轻”,但是协程的阿喀琉斯之踵在于进行带阻塞系统调用时执行体M会被阻塞,这就需要创建新的系统资源,而在高并发的web场景下如果使用阻塞的IO调用,网络IO大概率阻塞较长的时间,导致我们还是要创建大量的系统线程,所以Go需要尽量使用非阻塞的系统调用,虽然Go的标准库提供的是同步阻塞的IO模型,但底层其实是使用内核提供的非阻塞的IO模型。当Goroutine进行IO操作而数据未就绪时,syscall返回error,当前执行的Goroutine被置为阻塞态而M并没有被阻塞,P就可以继续使用当前执行体M继续执行下一个G,这样就不需要再去创建新的M。

 

当然只有非阻塞IO还不够,Go抽象了netpoller对象来进行IO多路复用,在linux下通过epoll来实现IO多路复用。当G由于IO未就绪而被置为阻塞态时,netpoller将对应的文件描述符注册到epoll实例中进行epoll_wait,就绪的文件描述符回调通知给阻塞的G,G更新为就绪状态等待调度继续执行,这种实现使得Golang在进行高并发的网络通信时变得非常强大,相比于php-fpm的多进程模型,Golang Http Server使用很少的线程资源运行非常多的Goroutine,而且尽可能的让每一个线程都忙碌起来,而不是阻塞在IO调用上,提高了CPU的利用率。

 

最后

Golang依靠协程和底层的IO多路复用模型,让我们可以简单的通过同步编程的方式来解决高并发的IO密集型操作,语言本身工程性也非常强。Golang基本保持着每半年发布一个正式版本的迭代速度,相信随着基础库的完善、GC的优化等,未来还会有更大的潜力。

 

 

参考资料:

http://m.yl1001.com/group_article/3231471449287668.htm

https://www.zhihu.com/question/21461752

 

 

posted @ 2021-06-27 21:24  junneyang  阅读(228)  评论(0编辑  收藏  举报