进程、线程与协程 (C# vs Python)

近来由于项目需要,接触了一下一直没去了解过的 Python 异步语法,发现和之前我熟悉的 C# 有很多不同。在深入 Python 的异步逻辑之后,由于 Python 在语法上保留了很多语言机制的细节(比如成员函数的 self 参数),我反而对 C# 的异步有了更深的了解。这里就来重新梳理一下各种并行方法的区别,以及他们在 C# 和 Python 上实现的区别。(这里只讨论单机的并行机制。)

总的来说,并行机制主要有进程 (Process)、线程 (Thread) 和协程 (Coroutine),其并行实现的开销依次递减,但是他们对每个任务的鲁棒性也是依次递减的。进程是操作系统资源分配的最小单元,线程则是能够被 CPU 并行处理的最小单元,而协程则是目前实现 “并行” 的最简单方法。一个进程中可以有多个线程,而一个线程中可以有多个协程。他们具体在特性上有以下区别

进程线程协程
独立内存堆××
独立处理器(可硬件并行)×
独立上下文×
独立栈、寄存器状态

进程

进程是系统层面实现并行的机制了,进程管理是现代操作系统的一大核心之一。进程之间互不影响,操作系统会保证一个程序崩溃了,其他程序以及系统内核不会崩溃。操作系统还会提供其他的进程管理功能,例如进程调度、设置进程优先级等等。不同语言底层对进程接口的实现实际上都是对系统接口的封装。

一些概念

与进程相关的概念通常都是操作系统课程的必修知识哈哈:

  • 进程间通信 (Inter-process communiation, IPC):故名思意。常用手段有管道、共享内存、信号量 (Semaphore)、消息队列等。
  • 管道 (Pipe):管道大概是进程间通信的最常用方式?分命名管道和匿名管道,进程双方均可往其中读写数据。
  • 远程过程调用 (Remote procedure call): 远程过程调用通过特定的消息序列化手段,可以实现进程间通信,其使用形式是把一个 “远程” 的函数在本地进行执行。
  • 进程锁:如果为了避免多个进程访问同一个资源的冲突的话,就会用到进程锁,其实现方法有管道、信号量、以及文件锁等。
  • 文件锁:文件锁是实现进程互斥的一种常用手段,只需要建立空文件句柄并锁上就可了~并且文件锁还能做到权限控制,非常方便~

C#

C# 中对进程控制的模块主要通过 System.Diagnostics.Process实现,可以实现建立进程、管理进程等,还可以指定具体的内存映射参数,如虚拟内存的页大小。而对管道的支持则是在 Process 类中有一部分,以及在 System.IO.Pipe里面有更全面的接口。我觉得这样的命名空间分类是挺合理的,Process 类的 API 其实只能用来进行程序调用和系统诊断,而 Pipe 则由于它和 Stream 的概念比较符合,因此归在 IO 空间下是合适的。

Python

Python 中对进程的控制以及通信方法的实现都在 multiprocess 包里,它的一些具体使用方法可以参考另一篇之前的博文。值得一提的是,Python 中还针对 Unix 系统提供了 fcntl, posix 等库专门用来调用系统底层 API,这些 API 有部分是和进程有关的。相关内容还是查阅对应的资料会比较清楚~

线程

线程是进程中细化的并行机制,线程的实现也需要用到操作系统的接口,不过线程的创建的管理基本都是在进程内部完成的。由于线程之间不独立内存空间,因此在 C++ 这种能够随意操作内存的语言中,一个线程崩了,这个进程也大概率就崩了。但是在 C# 和 Python 中,由于有比较完善的 Exception 机制,并且没有什么机会直接操作内存,一般线程崩了主进程还是能接着跑的。多线程想必应该是大家用的最多的并行方法了~

一些概念

在线程里面又有一些新的概念

  • 线程池 (Thread pool):线程池与内存池相似,都是为了避免频繁新建和销毁线程 (or 内存) 而造成额外的开销
  • 线程锁:线程锁与进程锁相似,是为了避免线程间访问同样的资源而产生冲突(例如 race condition)。线程间产生访问冲突非常常见,因此程序员掌握线程锁的使用是非常必要的。线程锁在 C++ 中的 <mutex> 有非常全面的实现。这里面锁的类型具有代表性,分为条件锁、自旋锁等等,具体区别可以参考这篇博客。C++ 的多线程非常令人头大... 这里就不展开了。
  • 事件 (Event):在多线程体系中,事件是一种常用于线程同步的机制,如果线程需要在运行过程中等待其他线程的运行,就可以使用事件机制。

C#

C# 中与线程相关的模块在 System.Threading空间下。System.Threading.Thread 提供了线程实现的类,使用 delegate 即可创建线程对象。这个空间底下也提供了 SpinLockSemaphoreMutex 等线程锁,以及 AutoResetEvent 实现了事件机制。System.Threading.ThreadPool 则提供了线程池的实现。另外需要指出的是 C# 提供了 lock 关键字,只需对冲突的对象使用 lock 锁上,那么在其对应的上下文中就能够避免冲突。

Python

Python 中与线程相关的对象在 threading模块中,其中 Thread 类提供了线程实现,Lock, Semaphore 提供了线程锁,Event 实现了事件机制。Python 中可以使用 with lock: 这样的块实现与 C#lock 相似的语法,但是这个地方的 lock 仍然需要自己声明,不如 C# 和 Java 中的 lock 用着方便。

总体而言 C# 和 Python 对多线程机制的支持都比较全面,然而 CPython 有一个臭名昭著的全局锁 GIL,使得其多线程效率大幅下降。因此在很多 Python 库中,大家宁愿使用 multiprocess 多进程来进行并行(即便需要处理进程间通信的问题),也不愿使用 threading 来完成并行任务。这一点上不得不说 Python 辣鸡!

协程

协程应该是 21 世纪才用的比较多的技术了,并且这个概念应该是在 Go 里面提的最多。在前文我提到协程是并行时打了引号,这是因为协程本质上还是同一个时刻只能干一件事,没法利用硬件并行,因此我们形容协程都是用 “异步”(Asychronized) 而不是 “并行”(Parallel)。异步是与同步相对的,只要程序能一会干点这个,一会干点那个,不按顺序来,那就可以称作异步了。协程的广泛应用是由于近些年大型服务器的负载越来越大,并发需求越来越高(同时剁手的人越来越多),多任务切换的开销越来越不可忽视,因此协程这个开销最小的方法就被广泛应用了。协程实际上不是一个比线程更小的概念,而是另一类概念(并行 / 串行 vs 异步 / 同步)。协程的特点是一个任务能够跑到一半就暂停,然后把状态存起来,等到需要的东西备齐了以后再把状态复原接着跑;至于暂停之前和之后是不是在同一个线程上跑、有没有跟别的任务一块跑并不重要。因此实际上协程是回调 (Callback) 机制的一个封装升级。

实际上不依赖于系统线程的并行技术不止协程一种,以下内容来自 StackExchange 的一个回答
A Fiber is a lightweight thread that uses cooperative multitasking instead of preemptive multitasking. A running fiber must explicitly "yield" to allow another fiber to run, which makes their implementation much easier than kernel or user threads.

A Coroutine is a component that generalizes a subroutine to allow multiple entry points for suspending and resuming execution at certain locations. Unlike subroutines, coroutines can exit by calling other coroutines, which may later return to the point where they were invoked in the original coroutine.

A Green Thread is a thread that is scheduled by a virtual machine (VM) instead of natively by the underlying operating system. Green threads emulate multithreaded environments without relying on any native OS capabilities, and they are managed in user space instead of kernel space, enabling them to work in environments that do not have native thread support.

一些概念

  • 事件循环 (Event loop):事件循环是一种非常简单的实现异步的机制,简而言之就是维护一个队列,然后把队列里的任务挨个执行,而任务随时随地可以被添加进队列。
  • 异步执行 / 等待 (async/await):这两个关键词在多个语言中都有出现。async 用来修饰函数,说明这个函数可以异步执行;await 用来等待异步函数的结束,如果没有结束就把当前任务搁着。

C#

C# 中没有协程的概念,C# 在 5.0 版本中引入的 async/await 关键字提供了异步执行的接口。据我所知 C# 应该是最早一批引入这个概念的语言了,并且 C# 里面 async 和 await 的使用非常顺滑~。C# 的 async/await 调度与 Go 一样,都是通过线程池实现,因此性能也非常不错。C# 中与 async/await 有关的接口在 System.Threading.Tasks下,里面的 Task 类型是对能够 await 的对象的封装。

C# 中也有用到 Event loop 来实现异步的地方,一般是在 UI 相关的函数中,例如整个 C# 里面的 event 机制都是通过事件循环来实现的。使用事件循环来完成与 UI 相关的异步应该是非常标准的做法了,例如 Qt 里面也有 QEventLoop 来实现 UI 的异步回调。与 Event loop 相关的是 Dispatcher 机制,Dispatcher 可以将指定任务加进事件循环中执行,例如在 WPF 中可以用 Window 的 Dispatcher 在其他线程中将任务加进 UI 主线程。

另外需要指出的是 C# 还可以通过 yield 关键词实现异步,yield return 可能是 C# 最早的异步机制了,不过功能有限,只能与 IEnumerable 合作使用。C# 中有一些协程的库(如 Unity 里的)就是使用 yield 机制来实现的。具体怎么使用 yield 还请去学习 C# 的语法~

Python

Python 对异步的支持就来的比较晚了,直到 PEP 492 才正式加入了对 async 关键字的支持,放在了 asyncio 模块中。Python 对这对关键词的实现又很辣鸡了,采用的是 Event loop 机制来实现(可能是因为多线程性能太差了吧 = =)。最让人蛋疼是为了执行异步函数你还需要自己开 event loop,如果你之前开过一个了,那你还需要把之前那个 loop 找回来,然后 dispatch 进去,这是何其难受!。。

Python 中只要对象有__await____aiter__或者__aenter__就可以分别支持 awaitasync forasync with 的代码块。Python 还设计了三个相关概念:Coroutine 代表异步对象、Task 代表异步执行计划、Future 代表异步执行结果。。何必呢???像 C# 用一个 Task 代表全部不行吗?再配合 event loop 的接口,就产生了 create_taskrun_coroutine_threadsaferun_until_completerun_in_executor 等我总是搞不清区别的函数。。。我爱 C#!


以上是我对C#和Python中异步机制的总结,我对各语言底层的了解并不深,如有错漏还请指点~