本文主要讨论 I/O 密集型任务,多线程自然适用于这个应用场景,因为它本身是为更高级的 CPU 密集型任务而设计的。然而多线程有很多明显的缺点,本文将通过简单的例子说明这一点,并解释为啥在用多线程时,我们常常(甚至是过多)使用线程锁。
线程和进程
在执行并行任务时,系统提供了两种工具:
- 线程 threading
- 进程 process
系统(operating System) 最重要的功能是让多个程序能同时运行,也就是说共享计算机的物理资源,尤其是 CPU 和内存,而不会互相影响。
通过底层构造,两个不同的进程在两个完全封闭的空间中运行,一个进程无法访问另一个进程的内存空间。但是使用多进程进行并行计算时,这种约束极大限制了其应用场景,因为很多时候不同的程序是相互依赖的,需要通信、共享数据等等。(固然存在一些内存共享库、通信库,但是它们本身又增加了程序的复杂性)。
一个进程中可以存在多个线程,每个线程有一个堆栈和一个程序指针,在线程内各指令顺序执行。多线程的好处是,它们共享其所属进程的内存。
调度器
线程和进程的运行由调度器完成,调度器有如下作用:
- 维护一个需要运行的进程和线程的列表
- 调度器以很高的频率轮流执行列表中各任务
- 经过所有的编译和优化阶段,调度器操作的基本上是二进制代码,非常接近处理器。
上下文切换
调度器决定挂起某个正在执行的进程或者线程,并执行另一个任务时,称为上下文切换。从一个进程切转到另一个进程,称为进程切换,从一个线程转到另一个线程,称为任务切换或者线程切换。
我们需要意识到一个重点是,调度器是一段通用代码,它以中立的方式来调度所有线程和进程,不受编程语言或者应用场景的影响,它分配给每个任务的时间基于处理器基本指令,也就是 周期。接下来我们主要关注线程,我们将会看到多线程可能带来的问题。
一个简单的加法运算
在单线程下,a=10
,计算机执行 a=a+1
主要包含如下三步:
- 从寄存器或缓存中读取变量
a
的值。 - 计算加法
- 将加法结果保存到之前
a
所在的内存空间。
在双线程下,计算两次 a=a+1
,理想情况下是这样的:
两个线程顺序执行,第一个线程执行完 a 的值为 11,调度器执行上下文切换,进入第二个线程,重复上述三步,得到最终结果 12。
但实际上,调度器并不一定能恰好在线程1执行完后,才切换到线程2,它区分不出线程1和线程2是两个相关的线程,有别于其它线程。如果调度器选择在线程1的第1步,加载完变量 a
的值后,切换到线程2,那么线程2看到的 a
还是原始值,那么两个线程执行的结果就是11,而非12。
在稍微高级一点的编程语言中,一个代码片段总会被翻译成几条二进制指令,供处理器使用。为了使程序在多线程模式下正常运行,一些代码片段,尤其是访问共享内存的片段,必须要连续执行,而不能被调度器随意打断。如果没有程序员的特殊说明,调度器无法知道,在二进制指令流中,哪些片段是不能被中断的,哪些片段做上下文切换是合法的。因此,锁的概念被提出,使程序员可以显式指定哪些代码片段不能被打断,必须一次执行完。
# thread A
a = a+1
# thread B
a = a+1
被替换为:
# thread A
get_lock(lock)
a = a+1
release_lock(lock)
# thread B
get_lock(lock)
a = a+1
release_lock(lock)
上述代码中,一个新的全局对象 lock
被引入,它有两种状态:占用和释放。
两个线程中先执行的那个得到了锁,将其设置为占用状态,该线程直到执行完毕后,才会释放锁;另一个线程必须要等到第一个线程完成后(锁处于释放状态),才会被执行。
总结
尽管我们可以使用多线程写出运行正常的程序,但代价是引入额外的代码,使问题复杂化。之所以会产生这种问题,主要是程序员对上下文切换没有控制权,而上下文切换导致的问题可能发生在多线程程序运行的任何时刻。在接下来的文章中,我们会看到 async/await.asyncio
范式是如何解决多线程带来的问题的。