1. 目录

  • 引言
  • 多线程技术的发展历史
    • 真空管和穿孔打卡
    • 晶体管和批处理系统
    • 集成电路和多道程序设计
    • 小结
  • 多线程技术引发的一些问题
    • 原子性
    • 可见性
    • 有序性
    • 性能问题
    • 小结
  • 参考资料

2. 引言

多线程是现代应用程序中必不可少技术,本文主要结合网络中现有的文章,并以客户端应用程序开发者的视角,对多线程技术进行系统梳理。

3. 多线程技术的发展历史

3.1 真空管和穿孔打卡

上个世纪最早一代的计算机,需要程序员先用纸笔写出程序,然后再将程序打孔到纸带或卡片上,最后输入到计算机中进行计算。

完整的工作流程为:写程序到纸上->手动打孔到卡片->输入到计算机->运行程序->打印计算结果。可以看出,除了运行程序阶段,其它阶段计算机都处于空闲状态,存在计算资源被浪费的问题。此外,每个程序每次都需要操作员手动输入到计算机中,存在过程较为繁琐的问题。

3.2 晶体管和批处理系统

为了解决真空管计算机存在的问题,人们先将多个程序批量录入到磁带上,然后把磁带输入到计算机中,一个接一个地自动连续处理程序。这种方式减少了操作员工作的繁琐程度,同时也在一定程度上提升了计算机的利用率。

但是,当遇到一些如磁盘等待、I/O操作等耗时操作时,CPU只能等待该I/O操作完成,再继续计算,还是存在计算机资源被浪费的问题。对于这个问题,举一个实际例子。有程序1、程序2先后录入到磁带中,计算机在执行程序1时有一个耗时的I/O操作,此时CPU被阻塞,处于空闲状态,而程序2却只能干等着。

3.3 集成电路和多道程序设计

3.3.1 多进程

为了解决批处理系统存在的问题,人们引入进程的概念。简单来说,就是将内存拆分成多个块,每一块装载一个程序,装载在内存中的这些程序也就是进程。当某一个进程在执行一些耗时的I/O操作或网络请求时,CPU会闲置下来,此时可将CPU的使用权切换给另一个进程,执行其程序指令。只要CPU的切换速度足够快,看起来像是多个进程同时运行一样。

到这里,基本解决了前面批处理系统存在的问题,但还是存在一些不足。一个程序中往往会有很多任务,这些任务并非一直是串行的。例如有QQ发送网络消息后,需要等待10s服务器响应,这期间即使QQ进程拿到CPU的使用权,其UI也会因CPU等待服务器响应而卡住。

3.3.2 多线程

为了解决上述的问题,人们引入了线程的概念。不严谨地说,线程与进程很相似,不过粒度更小。一个进程中可以创建很多线程去执行任务,例如前面QQ可以创建一个UI线程和网络请求线程,CPU会不断切换执行这两个线程的程序指令,看上去两个任务是并发的,QQ也就不会因为发送网络消息而导致UI卡住。事实上,随着多核CPU的发展,计算机中多个CPU可以同时执行不同线程的程序指令,达到了真正的并发效果。

下面这张图[3]很好地总结了进程与线程之间的关系。

3.4 小结

可以看出,从批处理到多进程,再到多线程,都是为了解决计算资源利用率不够高的问题,尽可能不让CPU闲置。

4. 多线程技术引发的一些问题

多线程技术的出现虽然解决计算资源利用率低的问题,但也产生了一些新的问题,下面将逐一介绍。

4.1 原子性

原子是指化学反应不可再分的基本微粒。正如其名,在计算机领域中,一般指一个不可再分的最小基本操作。

以C语言中的i++为例,虽然在日常开发中一般认为这个语句不可再拆分了,事实上编译器将它拆分成了如下3条CPU指令,因此不是原子操作。

1
2
3
ldr	w8, [sp, #12] // 从内存中读取变量i到寄存器中
add w8, w8, #1 // 数值+1
str w8, [sp, #12] // 写回内存
int i = 887这种整型变量的赋值操作,一般仅对应一条CPU指令,不可再继续拆分,因此是原子操作,下面是它的CPU指令。
1
mov	w8, #887
总而言之,程序执行的最小单元是CPU指令,因此需要在CPU层面上研究多线程问题。

下面用一个例子说明多线程的原子性问题。假设i是全局共享变量,初始值为8,我们用同时开两个线程去执行i++,期望最终结果是10,但在一种特殊情况下它的结果是9,下图说明了这种情况。

其中,编号①~⑥代表指令的执行顺序。下面是具体的细节:

  • 当执行②完的时候寄存器w8为9,注意这个时候变量i对应的内存还是8。
  • 此时CPU突然切换线程2去执行③,从内存读取变量i的值到寄存器w8,此时w8为8。
  • 执行④后w8为9。
  • CPU切换到线程一,并恢复线程一当时的上下文,执行⑤,将w8=9写入到变量i中。
  • CPU切换到线程二,并恢复线程二当时的上下文,执行⑥,将w8=9写入到变量i中。

可以看出,由于多线程切换的原因,导致最终i=9,与我们期待结果不符,造成了程序逻辑的错乱。对于这个问题,最常见的办法是使用锁技术,其缺点是会有一些性能上的损耗,在后面我们会用Swift语言来举例。

4.2 可见性

可见性问题是指一个线程修改了某一个共享变量的值时,其他线程是否能够立即知道这个修改。

查阅了一些资料,基本都在阐述一个观点:在多核CPU环境中,某个线程改了共享变量x的值,会先同步到其所在核心的CPU缓存中,没有及时同步到主存中,导致其他核心的线程不能及时读取共享变量x最新的值。一般在Java语言中,可采用volatile关键字或加锁的方式解决。

4.3 有序性

为了提高执行效率,CPU可能会将不存在依赖关系的指令进行重排,这在单线程中一般不会出现问题,但在多线程若有使用共享变量的情况,可能会导致运行逻辑与期望的不一致。与前面的问题一样,在Java语言中,可用volatile关键字或加锁的方式解决。

4.4 性能问题
  • 上下文切换:CPU在切换线程的时候需要保存原线程的上下文,并切换到新线程的上下文,这个过程需要额外的性能开销。
  • CPU缓存失效:每个线程执行任务可能不同,所用到的内存变量可能也不相同。因此,在切换线程后,原有缓存中仍存储着上一个线程常访问的数据,导致当前线程缓存命中率下降,需要从内存中取数据的概率变高,整体性能也就下降了。
  • 协作开销:如在Java中,为了保证线程安全,就有可能禁止编译器和CPU对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据同步到主存中,然后再从主内存同步到其他线程的工作内存中等。
4.5 小结

通过对多线程的问题的研究,可知,多线程在访问共享变量时是非常不安全的,代码的执行逻辑可能会预期不一致。在实际的多线程应用开发中,一般会用加锁的方式解决前文中提到的多线程安全问题。

参考资料

[1] 多线程发展史
[2] iOS多线程编程(七) 同步机制与锁
[3] Multithreading and Multiprocessing in 10 Minutes
[4] 多线程中的可见性问题
[5] Cache的基本原理
[6] 为什么多线程会带来性能问题?