基本功 | 一文讲清多线程和多线程同步
多线程编程是现代软件开发中的一项关键技术,在多线程编程中,开发者可以将复杂的任务分解为多个独立的线程,使其并行执行,从而充分利用多核处理器的优势。然而,多线程编程也带来了挑战,例如线程同步、死锁和竞态条件等问题。本篇文章将深入探讨多线程编程的基本概念(原子操作、CAS、Lock-free、内存屏障、伪共享、乱序执行等)、常见模式和最佳实践。通过具体的代码示例,希望能够帮助大家掌握多线程编程的核心技术,并在实际开发中应用这些知识,提升软件的性能和稳定性。
1 多线程
1.1 线程的概念
十多年前,主流观点主张在可能的情况下优先选择多进程而非多线程。如今,多线程编程已经成为编程领域的事实标准。多线程技术在很大程度上改善了程序的性能和响应能力,使其能够更加高效地利用系统资源,这不仅归功于多核处理器的普及和软硬件技术的进步,还归功于开发者对多线程编程的深入理解和技术创新。
那么什么是线程呢?线程是一个执行上下文,它包含诸多状态数据:每个线程有自己的执行流、调用栈、错误码、信号掩码、私有数据。Linux内核用任务(Task)表示一个执行流。
1.1.1 执行流
一个任务里被依次执行的指令会形成一个指令序列(IP寄存器值的历史记录),这个指令序列就是一个指令流,每个线程会有自己的执行流。考虑下面的代码(本文代码块为C++):
int calc(int a, int b, char op) {
int c = 0;
if (op == '+')
c = a + b;
else if (op == '-')
c = a - b;
else if (op == '*')
c = a * b;
else if (op == '/')
c = a / b;
else
printf("invalid operation\n");
return c;
}
calc函数被编译成汇编指令,一行C代码对应一个或多个汇编指令,在一个线程里执行calc,那么这些机器指令会被依次执行。但是,被执行的指令序列跟代码顺序可能不完全一致,代码中的分支、跳转等语句,以及编译器对指令重排、处理器乱序执行会影响指令的真正执行顺序。
1.1.2 逻辑线程 vs 硬件线程
线程可以进一步区分为逻辑线程和硬件线程。
逻辑线程
程序上的线程是一个逻辑上的概念,也叫任务、软线程、逻辑线程。线程的执行逻辑由代码描述,比如编写一个函数实现对一个整型数组的元素求和:
int sum(int a[], int n) {
int x = 0;
for (int i = 0; i < n; ++i)
x += a[i];
return x;
}
这个函数的逻辑很简单,它没有再调用其他函数(更复杂的功能逻辑可以在函数里调用其他函数)。我们可以在一个线程里调用这个函数对某数组求和;也可以把sum设置为某线程的入口函数,每个线程都会有一个入口函数,线程从入口函数开始执行。sum函数描述了逻辑,即要做什么以及怎么做,偏设计;但它没有描述物质,即没有描述这个事情由谁做,事情最终需要派发到实体去完成。
硬件线程
与逻辑线程对应的是硬件线程,这是逻辑线程被执行的物质基础。
芯片设计领域,一个硬件线程通常指为执行指令序列而配套的硬件单元,一个CPU可能有多个核心,然后核心还可能支持超线程,1个核心的2个超线程复用一些硬件。从软件的视角来看,无须区分是真正的Core和超出来的VCore,基本上可以认为是2个独立的执行单元,每个执行单元是一个逻辑CPU,从软件的视角看CPU只需关注逻辑CPU。一个软件线程由哪个CPU/核心去执行,以及何时执行,不归应用程序员管,它由操作系统决定,操作系统中的调度系统负责此项工作。
1.2 线程、核心、函数的关系
线程入口函数是线程执行的起点,线程从入口函数开始、一个指令接着一个指令执行,中间它可能会调用其他函数,那么它的控制流就转到了被调用的函数继续执行,被调用函数里还可以继续调用其他函数,这样便形成一个函数调用链。
前面的数组求和例子,如果数组特别大,则哪怕是一个简单的循环累加也可能耗费很长的时间,可以把这个整型数组分成多个小数组,或者表示成二维数组(数组的数组),每个线程负责一个小数组的求和,多个线程并发执行,最后再累加结果。
所以,为了提升处理速度,可以让多个线程在不同数据区段上执行相同(或相似)的计算逻辑,同样的处理逻辑可以有多个执行实例(线程),这对应对数据拆分线程。当然,也可以为两个线程指定不同的入口函数,让各线程执行不同的计算逻辑,这对应对逻辑拆分线程。
我们用一个例子来阐述线程、核心和函数之间的关系,假设有遛狗、扫地两类工作要做:
- 遛狗就是为狗系上绳子然后牵着它在小区里溜达一圈,这句话就描述了遛狗的逻辑,即对应到函数定义,它是一个对应到设计的静态的概念。
- 每项工作,最终需要人去做,人就对应到硬件:CPU/Core/VCore,是任务被完成的物质基础。
那什么对应软件线程? 任务拆分。
一个例子
假设现在有2条狗需要遛、3个房间需要打扫。可以把遛狗拆成2个任务,一个任务是遛小狗,另一个任务是遛大狗;打扫房间拆分为3个任务,3个房间对应3个任务,执行这样的拆分策略后,将会产生2+3=5个任务。但如果只有2个人,2个人无法同时做5件事,让某人在某时干某事由调度系统负责。
如果张三在遛小狗,那就对应一个线程被执行,李四在扫房间A,则表示另一个线程在执行中,可见线程是一个动态的概念。
软件线程不会一直处于执行中,原因是多方面的。上述例子是因为人手不够,所以遛大狗的任务还处于等待被执行的状态,其他的原因包括中断、抢占、条件依赖等。比如李四扫地过程中接到一个电话,他需要去处理更紧急的事情(接电话),则扫地这个事情被挂起,李四打完电话后继续扫地,则这个线程会被继续执行。
如果只有1个人,则上述5个任务依然可以被依次或交错完成,所以多线程是一个编程模型,多线程并不一定需要多CPU多Core,单CPU单Core系统依然可以运行多线程程序(虽然最大化利用多CPU多Core的处理能力是多线程程序设计的一个重要目标)。1个人无法同时做多件事,单CPU/单Core也不可以,操作系统通过时间分片技术应对远多于CPU/Core数的多任务执行的挑战。也可以把有些任务只分配给某些人去完成,这对应到CPU亲和性和绑核。
1.3 程序、进程、线程、协程
进程和线程是操作系统领域的两个重要概念,两者既有区别又有联系。
1.3.1 可执行程序
C/C++源文件经过编译器(编译+链接)处理后,会产生可执行程序文件,不同系统有不同格式,比如Linux系统的ELF格式、Windows系统的EXE格式,可执行程序文件是一个静态的概念。
1.3.2 进程是什么
可执行程序在操作系统上的一次执行对应一个进程,进程是一个动态的概念:进程是执行中的程序。同一份可执行文件执行多次,会产生多个进程,这跟一个类可以创建多个实例一样。进程是资源分配的基本单位。
1.3.3 线程是什么
一个进程内的多个线程代表着多个执行流,这些线程以并发模式独立执行。操作系统中,被调度执行的最小单位是线程而非进程。进程是通过共享存储空间对用户呈现的逻辑概念,同一进程内的多个线程共享地址空间和文件描述符,共享地址空间意味着进程的代码(函数)区域、全局变量、堆、栈都被进程内的多线程共享。
1.3.4 进程和线程的关系
先看看linus的论述,在1996年的一封邮件里,Linus详细阐述了他对进程和线程关系的深刻洞见,他在邮件里写道:
- 把进程和线程区分为不同的实体是背着历史包袱的传统做法,没有必要做这样的区分,甚至这样的思考方式是一个主要错误。
- 进程和线程都是一回事:一个执行上下文(context of execution),简称为COE,其状态包括:
- CPU状态(寄存器等)
- MMU状态(页映射)
- 权限状态(uid、gid等)
- 各种通信状态(打开的文件、信号处理器等)
- 传统观念认为:进程和线程的主要区别是线程有CPU状态(可能还包括其他最小必要状态),而其他上下文来自进程;然而,这种区分法并不正确,这是一种愚蠢的自我设限。
- Linux内核认为根本没有所谓的进程和线程的概念,只有COE(Linux称之为任务),不同的COE可以相互共享一些状态,通过此类共享向上构建起进程和线程的概念。
- 从实现来看,Linux下的线程目前是LWP实现,线程就是轻量级进程,所有的线程都当作进程来实现,因此线程和进程都是用task_struct来描述的。这一点通过/proc文件系统也能看出端倪,线程和进程拥有比较平等的地位。对于多线程来说,原本的进程称为主线程,它们在一起组成一个线程组。
- 简言之,内核不要基于进程/线程的概念做设计,而应该围绕COE的思考方式去做设计,然后,通过暴露有限的接口给用户去满足pthreads库的要求。