清水泥沙

golang基础(39.多进程,多线程,携程)

为什么需要并发编程

在PHP中并不存在并发的概念,PHP中所有的操作都是串行执行,同步阻塞的。这就是很多人诟病PHP性能低下的原因。但串行执行虽然性能上存在问题,但是相对的也有它的好处。

  • 保证了PHP的简单性,不需要考虑并发引入的线程安全问题
  • 不需要考虑加锁来保证某个操作的原子性
  • 不存在线程间的通讯问题

与并发相对的是串行,即按照代码顺序一步一步往下执行,当遇到某个IO操作时,比如发送邮件,读取文件,查询数据库。CPU会进行等待,等到IO操作完成后才会继续执行代码。这种情况在某些要求高并发高性能的业务场景显然是不合适的,从操作系统上来讲,多个任务是可以同时执行的,因为CPU本身就是多核的,能同时执行多任务的计算。哪怕是单核CPU,也可利用时间分片的方式在多个进程和线程之间来回切换执行。比如说当某个任务执行时遇到了IO操作,这个时候CPU不会一直傻傻等待,而是挂起这个任务,让出CPU时间片给到其他任务。然后等这个IO操作完成后,通知CPU恢复后续代码的执行。实际上CPU大部分时间都在做这种调度,并发编程就是最大程度的压榨CPU,从而提高程序的性能和效率

并发编程的常见实现

  • 多进程。多进程是基于操作系统层面的并发基本模式,同时也是开销最大的模式。在linux上很多工具都采用这种模式在工作,比如PHP-FPM,他有专门的主进程监听端口以及管理连接,还有多个工作进程对具体请求进行处理。这种方式好处是在于简单,进程间互相不影响,不同进程间数据相互隔离。缺点是系统开销大,每个进程都是由内核管理的。
  • 多线程。多线层是基于系统层面的并发模式,它是基于进程内的,也是使用较多也相对有效的方式。线程比进程开销更小,线程间会共享数据,线程切换和调度会加锁会造成额外的性能开销。线程比进程轻量,但在高并发的情况下效率依然有影响,例如C10K问题,即支持一万个并发需要一万个线程,这样对系统资源有较高的要求,而且CPU管理这些线程带来巨大负担
  • 携程。一种用户态线程,可以交由程序员调度的,你可以将其看作是轻量级别的线程,不许要操作系统来进行抢占式调度,系统开销极小,携程内有自己独立的堆栈调度间没有线程的加锁开销。
  • 基于回调的非阻塞IO/异步IO。为了解决C10K的问题,在很多高并发的开发实践中,都会通过事件驱动的方式来使用异步IO,在这种模式下,一个线程维护多个Socket连接,从而降低了系统的开销。

传统并发模式的缺陷

在串行化模式下执行的程序,所有的事务都具备确定性的,比如程序预设了123个步骤,代码会严格按照顺序执行下去,即使在某哥步骤中阻塞了,也会一直等待代码执行结束才会进行下一步。多线程的并发模式下,就彻底打破了这种缺定性。比如我们原先的123,第2步是一个耗时操作,这时候我们启动了一个新的线程对其进行处理,这时候我们无法确定的是,主线程拉起2的子线程后继续往下执行代码,我们无法确定是主线程先执行完毕退出程序,还是2的线程先完成。如果是主线程完成退出会导致2的子线程操作中断。或者我们在第3步的时候依赖第2步的某个返回结果,我们不知道啥时候能够返回这个结果,如果第2、3步有相互依赖的变量,甚至可能出现死锁,以及我们如何在主线程中获取新线程的异常和错误信息并进行相应的处理,等等,这种不确定性给程序的行为带来了意外和危害,也让程序变得不可控。不同的线程好比平行时空,我们需要通过线程间通信来告知不同线程目前各自运行的状态和结果,以便使程序可控,线程之间通信可以通过共享内存的方式(参考 Swoole 中的 Swoole Table),即在不同线程中操作的是同一个内存地址上存储的值。为了保证共享内存的有效性,需要采取很多措施,比如加锁来避免死锁或资源竞争,还是以上面的主线程和新线程为例,如果我们在第1步获取了一个中间结果,第2步和第3步都要对这个中间结果进行操作,如果不加锁保证操作的原子性,很有可能产生脏数据。诸如此类的问题在生产环境极有可能造成重大故障甚至事故,而且不易察觉和调试。我们可以将线程加共享内存的方式称为「共享内存系统」。为了解决共享内存系统存在的问题,计算机科学家们又提出了「消息传递系统」。「消息传递系统」指的是将线程间共享状态的各种操作都封装在线程之间传递的消息中,这通常要求发送消息时对状态进行复制,并且在消息传递的边界上交出这个状态的所有权。从表明上来看,这个操作与「共享内存系统」中执行的通过加锁实现原子更新操作相同,但从底层实现上来看则不同:一个对同一个内存地址持有的值进行操作,一个是从消息通道读取数据并处理。由于需要执行状态复制操作,所以大多数消息传递的实现在性能上并不优越,但线程中的状态管理工作则会变得更加简单,这就有点像我们在开篇讲 PHP 不支持并发编程提到的那样,如果想让编码简单,性能就要做牺牲,如果想追求性能,代码编写起来就比较费劲,这也是我们为什么通常不会直接通过事件驱动的异步 IO 来实现并发编程一样,因为这涉及到直接调用操作系统底层的库函数(select、epoll、libevent 等)来实现,非常复杂。

Go 语言协程支持

与传统的系统级线程和进程相比,协程的最大优势在于轻量级(可以看作用户态的轻量级线程),我们可以轻松创建上百万个协程而不会导致系统资源衰竭,而线程和进程通常最多也不能超过 1 万个(C10K问题)。多数语言在语法层面并不直接支持协程,而是通过库的方式支持,比如 PHP 的 Swoole 扩展库,但用库的方式支持的功能通常并不完整,比如仅仅提供轻量级线程的创建、销毁与切换等能力。如果在这样的轻量级线程中调用一个同步 IO 操作,比如网络通信、本地文件读写,都会阻塞其他的并发执行轻量级线程,从而无法真正达到轻量级线程本身期望达到的目标。Go 语言在语言级别支持协程,称之为 goroutine。Go 语言标准库提供的所有系统调用操作(当然也包括所有同步 IO 操作),都有协程的身影。协程间的切换管理不依赖于系统的线程和进程,也不依赖于 CPU 的核心数量,这让我们在 Go 语言中通过协程实现并发编程变得非常简单。