有了多线程,为什么还要有协程?
早期编程都是基于单进程来进行,随着计算机技术的发展,当下推出的通用操作系统都引入了线程,以便进一步提高系统的并发性,并把它视为现代操作系统的一个重要指标。既然线程可以解决高并发问题,为什么后来人们又搞出了协程呢?或者说协程的出现到底是为了解决什么问题?
随着计算机行业的发展,摩尔定律越来越难维持,作为研发的我们知道,一台主机上的资源总是有限的,CPU、内存、磁盘、网卡,在高并发场景下,这些都会成为制约程序性能的因素,在上古时期的程序员是如何处理同时的上百个请求呢?那么多进程模式是最初的解决方案。
进程
什么是进程
进程,描述的就是程序的执行过程,是运行着的程序的代表。换句话说,一个进程其实就是某个程序运行时的一个产物。如果说静静地躺在那里的代码就是程序的话,那么奔跑着的、正在发挥着既有功能的代码就可以被称为进程。
进程的调度
一颗CPU同时只能执行一个程序,那我们电脑上的QQ、浏览器、钉钉等,又是如何同时运行的呢?
在计算机的世界里,内核把CPU的执行时间切分成许多时间片,比如1秒钟可以切分为100个10毫秒的时间片,每个时间片再分发给不同的进程,通常,每个进程需要多个时间片才能完成一个请求。这样,虽然微观上,比如说就这10毫秒时间CPU只能执行一个进程,但宏观上1秒钟执行了100个时间片,于是每个时间片所属进程中的请求也得到了执行,这就实现了请求的并发执行。
CPU就好像一个流水线上的工人,不断的处理流水线上的各种信息包裹,打开包裹读取指令并执行,遇到执行慢的IO调用(或执行时间片结束)则会暂时把它放到等候区,继续处理流水线上下一个等待处理的包裹。等候区有很多这样的包裹,等待着系统的IO执行完成,当IO调用结束后,又开始进入到等待处理队列。
进程的缺点
在操作系统中,每个进程的内存空间都是独立的,这样用多进程实现并发就有两个缺点:一是内核的管理成本高,二是无法简单地通过内存同步数据,很不方便。于是,多线程模式就出现了。
线程
什么是线程
线程,是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。线程总是在进程之内的。一个进程至少会包含一个线程。
如果一个进程只包含了一个线程,那么它里面的所有代码都只会被串行地执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以被称为其所属进程的主线程。相对应的,如果一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的。所以,线程可以通过共享内存地址空间,解决内核的管理成本、内存同步数据的问题。
线程的调度与缺点
在同一个进程中并行运行多个线程,是对在同一台计算机上并行运行多个进程的模拟。因此,线程也被称为轻量级进程。与进程调度类似,CPU在线程之间快速切换,制造了线程并行运行的假象。
由于各个线程都可以访问进程地址空间的每一个内存地址,所以一个线程可以读、写,甚至清除另一个线程的堆栈。也就是说,线程之间是没有保护的。但要注意的是,每个线程都有自己的堆栈、程序计数器、寄存器等信息,这些不是共享的。
共享地址空间虽然可以方便地共享对象,但这也导致一个问题,那就是任何一个线程出错时,进程中的所有线程会跟着一起崩溃。这也是如Nginx等强调稳定性的服务坚持使用多进程模式的原因。事实上,无论基于多进程还是多线程,都难以实现高并发,这由三个原因所致。
线程内存占用
一是单个线程消耗的内存过多,比如64位的Linux为每个线程的栈分配了8MB的内存,通过ulimit -s可以查看线程的默认分配的内存。单位kb。
线程竞争
为了解决线程申请堆内存时,互相竞争的问题。每个线程预先在这个空间内申请堆空间还预分配了64MB的内存作为堆内存池。所以,我们没有足够的内存去开启几万个线程实现并发。
线程切换耗时
线程的切换是由内核控制的,什么时候会切换线程呢?不只时间片用尽,当调用阻塞方法时,内核为了CPU 充分工作,也会切换到其他线程执行。一次上下文切换的成本在几十纳秒到几微秒间,当线程繁忙且数量众多时,这些切换会消耗绝大部分的CPU运算能力。
下图以磁盘IO为例,描述了多线程中使用阻塞方法读磁盘,2个线程间的切换方式。
那么,怎么才能实现高并发呢?把上图中本来由内核实现的请求切换工作,交由用户态的代码来完成就可以了。
协程
什么是协程
协程就是用户态的线程。通常创建协程时,会从进程的堆中分配一段内存作为协程的栈。线程的栈有8MB,而协程栈的大小通常只有几十 KB。而且,C库内存池也不会为协程预分配内存,它感知不到协程的存在。这样,更低的内存占用空间为高并发提供了保证,毕竟十万并发请求,就意味着10万个协程。
协程的调度
每个协程有独立的栈,而栈既保留了变量的值,也保留了函数的调用关系、参数和返回值,CPU中的栈寄存器SP指向了当前协程的栈,而指令寄存器IP保存着下一条要执行的指令地址。因此,从协程1切换到协程2时,首先要把SP、IP寄存器的值为线程1保存下来,再从内存中找出协程2上一次切换前保存好的寄存器值,写入CPU的寄存器,这样就完成了协程切换。(Swoole4实现原理相似。)
在GO语言中,语言的运行时系统会帮助我们自动地创建和销毁系统级的线程。这里的系统级线程指的就是我们刚刚说过的操作系统提供的线程。而对应的用户级协程指的是架设在系统级线程之上的,由我们编写的程序完全控制的代码执行流程。用户级协程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要我们的程序自己去实现和处理。这带来了很多优势,比如,因为它们的创建和销毁并不用通过操作系统去做,所以速度会很快,又比如,由于不用等着操作系统去调度它们的运行,所以往往会很容易控制并且可以很灵活。
GMP并发编程模型
这个调度器是Go语言运行时系统的重要组成部分,它主要负责统筹调配Go并发编程模型中的三个主要元素,即:G(goroutine 的缩写)、P(processor 的缩写)和 M(machine 的缩写)。
其中的M指代的就是系统级线程。而P指的是一种可以承载若干个G,且能够使这些G适时地与M进行对接,并得到真正运行的中介。
当一个正在与某个M对接并运行着的G,需要因某个事件(比如等待 I/O或锁的解除)而暂停运行的时候,调度器总会及时地发现,并把这个G与那个M分离开,以释放计算资源供那些等待运行的G使用。而当一个G需要恢复运行的时候,调度器又会尽快地为它寻找空闲的计算资源(包括M)并安排运行。另外,当M不够用时,调度器会帮我们向操作系统申请新的系统级线程,而当某个M已无用时,调度器又会负责把它及时地销毁掉。所以Go程序总是能高效地利用操作系统和计算机资源。程序中的所有goroutine也都会被充分地调度,其中的代码也都会被并发地运行,即使这样的goroutine有数以十万计,也仍然可以如此。
PHP协程的问题
为了保证所有切换都在用户态进行,协程必须重新封装所有的阻塞系统调用,否则,一旦协程触发了线程切换,会导致这个线程进入休眠状态,进而其上的所有协程都得不到执行。所以,协程的高性能,建立在切换必须由用户态代码完成之上,这要求协程生态是完整的,要尽量覆盖常见的组件。这也是为什么Swoole提供了MySQL、Redis等客户端的协程SDK,在使用协程开发时,阻塞的socket调用都应该需要改造成协程调用后,才能在协程中使用。
总结
为什么要搞出和用协程呢?总结起来看:
一是节省CPU,避免系统内核级的线程频繁切换,造成的CPU资源浪费。好钢用在刀刃上。而协程是用户态的线程,用户可以自行控制协程的创建于销毁,极大程度避免了系统级线程上下文切换造成的资源浪费。
二是节约内存,在64位的Linux中,一个线程需要分配8MB栈内存和64MB堆内存,系统内存的制约导致我们无法开启更多线程实现高并发。而在协程编程模式下,可以轻松有十几万协程,这是线程无法比拟的。
三是稳定性,前面提到线程之间通过内存来共享数据,这也导致了一个问题,任何一个线程出错时,进程中的所有线程都会跟着一起崩溃。
四是开发效率,使用协程在开发程序之中,可以很方便的将一些耗时的IO操作异步化,例如写文件、耗时IO请求等。
版权声明: 本文为 InfoQ 作者【八两】的原创文章。
原文链接:【http://xie.infoq.cn/article/c62beed510625a8e128aa9001】。文章转载请联系作者。
评论