做男女之间的事情的网站,做网站费用记入什么会计科目,美图秀秀可以做网站吗,企业营销的网站1 多线程
1.1 线程的概念
十多年前#xff0c;主流观点主张在可能的情况下优先选择多进程而非多线程#xff0c;如今#xff0c;多线程编程已经成为编程领域的事实标准。多线程技术在很大程度上改善了程序的性能和响应能力#xff0c;使其能够更加高效地利用系统资源主流观点主张在可能的情况下优先选择多进程而非多线程如今多线程编程已经成为编程领域的事实标准。多线程技术在很大程度上改善了程序的性能和响应能力使其能够更加高效地利用系统资源这不仅归功于多核处理器的普及和软硬件技术的进步还归功于开发者对多线程编程的深入理解和技术创新。
那么什么是线程呢线程是一个执行上下文它包含诸多状态数据每个线程有自己的执行流、调用栈、错误码、信号掩码、私有数据。Linux内核用任务Task表示一个执行流。
1.1.1 执行流
一个任务里被依次执行的指令会形成一个指令序列IP寄存器值的历史记录这个指令序列就是一个指令流每个线程会有自己的执行流。考虑下面的代码
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;elseprintf(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是任务被完成的物质基础
那什么对应软件线程任务拆分。
相关视频推荐
c/c面试常见的进程、线程问题分析
高并发场景3种锁方案自旋锁、互斥锁、原子操作的优缺点
死锁形成的原因死锁的检测方案为你项目一个小组件预防死锁
免费学习地址c/c linux服务器开发/后台架构师
需要C/C Linux服务器架构师学习资料加qun812855908获取资料包括C/CLinuxgolang技术NginxZeroMQMySQLRedisfastdfsMongoDBZK流媒体CDNP2PK8SDockerTCP/IP协程DPDKffmpeg等免费分享 一个例子 假设现在有2条狗需要遛、3个房间需要打扫。可以把遛狗拆成2个任务一个任务是遛小狗另一个任务是遛大狗打扫房间拆分为3个任务3个房间对应3个任务执行这样的拆分策略后将会产生235个任务。但如果只有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的论述在96年的一封邮件里linus详细阐述了他对进程和线程关系的深刻洞见他在邮件里写道 把进程和线程区分为不同的实体是背着历史包袱的传统做法没有必要做这样的区分甚至这样的思考方式是一个主要错误。 进程和线程都是一回事一个执行上下文context of execution简称为COE其状态包括 CPU状态寄存器等 MMU状态页映射 权限状态uid、gid等 各种通信状态打开的文件、信号处理器等 传统观念认为进程和线程的主要区别是线程有CPU状态可能还包括其他最小必要状态而其他上下文来自进程然而这种区分法并不正确这是一种愚蠢的自我设限。 linux内核认为根本没有所谓的进程和线程的概念只有COELinux称之为任务不同的COE可以相互共享一些状态通过此类共享向上构建起进程和线程的概念。 从实现来看Linux下的线程目前是LWP实现线程就是轻量级进程所有的线程都当作进程来实现因此线程和进程都是用task_struct来描述的。这一点通过/proc文件系统也能看出端倪线程和进程拥有比较平等的地位。对于多线程来说原本的进程称为主线程它们在一起组成一个线程组。 简言之内核不要基于进程/线程的概念做设计而应该围绕COE的思考方式去做设计然后通过暴露有限的接口给用户去满足pthreads库的要求。
1.3.5 协程
用户态的多执行流上下文切换成本比线程更低微信用协程改造后台系统后获得了更大吞吐能力和更高稳定性如今协程库也进了C20新标准。
1.4 为什么需要多线程
1.4.1 什么是多线程
一个进程内多个线程并发执行的情况就叫多线程每个线程是一个独立的执行流多线程是一种编程模型它与处理器无关、跟设计有关。
需要多线程的原因包括 并行计算充分利用多核提升整体吞吐加快执行速度 后台任务处理将后台线程和主线程分离在特定场景它是不可或缺的如响应式用户界面、实时系统等
我们用2个例子作说明
1.4.2 通过多线程并发提升处理能力
假设你要编写一个程序用于统计一批文本文件的单词出现次数程序的输入是文件名列表输出一个单词到次数的映射。
// 类型别名单词到次数的映射
using word2count std::mapstd::string, unsigned int;// 合并“单词到次数映射列表”
word2count merge(const std::vectorword2count w2c_list) {/*todo*/}// 统计一个文件里单词出现次数单词到次数的映射
word2count word_count_a_file(const std::string file) {/*todo*/}// 统计一批文本文件的单词出现次数
word2count word_count_files(const std::vectorstd::string files) {std::vectorword2count w2c_list;for (auto file : files) {w2c_list.push_back(word_count_a_file(file));}return merge(w2c_list);
}int main(int argc, char* argv[]) {std::vectorstd::string files;for (int i 1; i argc; i) {files.push_back(argv[i]);}auto w2c word_count_files(files);return 0;
}
这是一个单线程程序word_count_files函数在主线程里被main函数调用。如果文件不多、又或者文件不大那么运行这个程序很快就会得到统计结果否则可能要等一段长的时间才能返回结果。
重新审视这个程序会发现函数word_count_a_file接受一个文件名吐出从该文件计算出的局部结果它不依赖于其他外部数据和逻辑可以并发执行所以可以为每个文件启动一个单独的线程去运行word_count_a_file等到所有线程都执行完再合并得到最终结果。
实际上为每个文件启动一个线程未必合适因为如果有数万个小文件那么启动数万个线程每个线程运行很短暂的时间大量时间将耗费在线程创建和销毁上一个改进的设计 开启一个线程池线程数等于Core数或二倍Core数策略 每个工作线程尝试去文件列表文件列表需要用锁保护起来里取一个文件 成功统计这个文件的单词出现次数 失败该工作线程就退出 待所有工作线程退出后在主线程里合并结果
这样的多线程程序能加快处理速度前面数组求和可以采用相似的处理如果程序运行在多CPU多Core的机器上就能充分利用多CPU多Core硬件优势多线程加速执行是多线程的一个显而易见的主要目的此其一。
1.4.3 通过多线程改变程序编写方式
其二有些场景会有阻塞的调用如果不用多线程那么代码不好编写。
比如某程序在执行密集计算的同时需要监控标准输入键盘如果键盘有输入那么读取输入并解析执行但如果获取键盘输入的调用是阻塞的而此时键盘没有输入到来那么其他逻辑将得不到机会执行。
代码看起来会像下面这样子
// 从键盘接收输入经解释后会构建一个Command对象返回
Command command getCommandFromStdInput();
// 执行命令
command.run();
针对这种情况我们通常会开启一个单独的线程去接收输入而用另外的线程去处理其他计算逻辑避免处理输入阻塞其他逻辑处理这也是多线程的典型应用它改变了程序的编写方式此其二。
1.5 线程相关概念
1.5.1 时间分片
CPU先执行线程A一段时间然后再执行线程B一段时间然后再执行线程A一段时间CPU时间被切分成短的时间片、分给不同线程执行的策略就是CPU时间分片。时间分片是对调度策略的一个极度简化实际上操作系统的调度策略非常精细要比简单的时间分片复杂的多。如果一秒钟被分成大量的非常短的时间片比如100个10毫秒的时间片10毫秒对人的感官而言太短了以致于用户觉察不到延迟仿佛计算机被该用户的任务所独占实际上并不是操作系统通过进程的抽象获得了这种任务独占CPU的效果另一个抽象是进程通过虚拟内存独占存储。
1.5.2 上下文切换
把当前正在CPU上运行的任务迁走并挑选一个新任务到CPU上执行的过程叫调度任务调度的过程会发生上下文切换context swap即保存当前CPU上正在运行的线程状态并恢复将要被执行的线程的状态这项工作由操作系统完成需要占用CPU时间sys time。
1.5.3 线程安全函数与可重入
一个进程可以有多个线程在同时运行这些线程可能同时执行一个函数如果多线程并发执行的结果和单线程依次执行的结果是一样的那么就是线程安全的反之就不是线程安全的。
不访问共享数据共享数据包括全局变量、static local变量、类成员变量只操作参数、无副作用的函数是线程安全函数线程安全函数可多线程重入。每个线程有独立的栈而函数参数保存在寄存器或栈上局部变量在栈上所以只操作参数和局部变量的函数被多线程并发调用不存在数据竞争。
C标准库有很多编程接口都是非线程安全的比如时间操作/转换相关的接口ctime()/gmtime()/localtime()c标准通过提供带_r后缀的线程安全版本比如
char* ctime_r(const time* clock, char* buf);
这些接口的线程安全版本一般都需要传递一个额外的char* buf参数这样的话函数会操作这块buf而不是基于static共享数据从而做到符合线程安全的要求。
1.5.4 线程私有数据
因为全局变量包括模块内的static变量是进程内的所有线程共享的但有时应用程序设计中需要提供线程私有的全局变量这个变量仅在函数被执行的线程中有效但却可以跨多个函数被访问。
比如在程序里可能需要每个线程维护一个链表而会使用相同的函数来操作这个链表最简单的方法就是使用同名而不同变量地址的线程相关数据结构。这样的数据结构可以由Posix线程库维护成为线程私有数据 (Thread-specific Data或称为 TSD)。
Posix有线程私有数据相关接口而C/C等语言提供thread_local关键字在语言层面直接提供支持。
1.5.5 阻塞和非阻塞
一个线程对应一个执行流正常情况下指令序列会被依次执行计算逻辑会往前推进。但如果因为某种原因一个线程的执行逻辑不能继续往前走那么我们就说线程被阻塞住了。就像你下班回家但走到家门口发现没带钥匙你在门口徘徊任由时间流逝而不能进入房间。
线程阻塞的原因有很多种比如 线程因为acquire某个锁而被操作系统挂起如果acquire睡眠锁失败线程会让出CPU操作系统会调度另一个可运行线程到该CPU上执行被调度走的线程会被加入等待队列进入睡眠状态 线程调用了某个阻塞系统调用而等待比如从没有数据到来的套接字上读数据从空的消息队列里读消息 线程在循环里紧凑的执行测试设置指令并一直没有成功虽然线程还在CPU上执行但它只是忙等相当于白白浪费CPU后面的指令没法执行逻辑同样无法推进
如果某个系统调用或者编程接口有可能导致线程阻塞那么便被称之为阻塞系统调用与之对应的是非阻塞调用调用非阻塞的函数不会陷入阻塞如果请求的资源不能得到满足它会立即返回并通过返回值或错误码报告原因调用的地方可以选择重试或者返回。
2 多线程同步
前面讲了多线程相关的基础知识现在进入第二个话题多线程同步。
2.1 什么是多线程同步
同一进程内的多个线程会共享数据对共享数据的并发访问会出现race condition这个词的官方翻译是竞争条件但condition翻译成条件令人困惑特别是对初学者而言它不够清晰明了翻译软件显示condition有状况、状态的含义可能翻译成竞争状况更直白。
多线程同步是指 协调多个线程对共享数据的访问避免出现数据不一致的情况 协调各个事件的发生顺序使多线程在某个点交汇并按预期步骤往前推进比如某线程需要等另一个线程完成某项工作才能开展该线程的下一步工作
要掌握多线程同步需先理解为什么需要多线程同步、哪些情况需要同步。
2.2 为什么需要同步
理解为什么要同步Why是多线程编程的关键它甚至比掌握多线程同步机制How本身更加重要。识别什么地方需要同步是编写多线程程序的难点只有准确识别需要保护的数据、需要同步的点再配合系统或语言提供的合适的同步机制才能编写安全高效的多线程程序。
下面通过几个例子解释为什么需要同步。
示例1
有1个长度为256的字符数组msg用于保存消息函数read_msg()和write_msg()分别用于msg的读和写
// example 1
char msg[256] this is old msg;char* read_msg() {return msg;
}void write_msg(char new_msg[], size_t len) {memcpy(msg, new_msg, std::min(len, sizeof(msg)));
}void thread1() {char new_msg[256] this is new msg, its too looooooong;write_msg(new_msg, sizeof(new_msg));
}void thread2() {printf(msg%s\n, read_msg());
}
如果线程1调用write_msg()线程2调用read_msg()并发操作不加保护。因为msg的长度是256字节完成长达256字节的写入需要多个内存周期在线程1写入新消息期间线程2可能读到不一致的数据。即可能读到this is new msg而后半段内容its very...线程1还没来得及写入它不是完整的新消息。
在这个例子中不一致表现为数据不完整。
示例2
比如对于二叉搜索树BST的节点一个结构体有3个成分 一个指向父节点的指针 一个指向左子树的指针 一个指向右子树的指针
// example 2
struct Node {struct Node *parent;struct Node *left_child, *right_child;
};
这3个成分是有关联的将节点加入BST要设置这3个指针域从BST删除该节点要修改该节点的父、左孩子节点、右孩子节点的指针域。对多个指针域的修改不能在一个指令周期完成如果完成了一个成分的写入还没来得修改其他成分就有可能被其他线程读到了但此时节点的有些指针域还没有设置好通过指针域去取数可能会出错。
示例3
考虑两个线程对同一个整型变量做自增变量的初始值是0我们预期2个线程完成自增后变量的值为2。
// example 3
int x 0; // 初始值为0
void thread1() { x; }
void thread2() { x; }
简单的自增操作包括三步 加载从内存中读取变量x的值存放到寄存器 更新在寄存器里完成自增 保存把位于寄存器中的x的新值写入内存
两个线程并发执行x让我们看看真实情况是什么样的 如果2个线程先后执行自增在时间上完成错开。无论是1先2后或是2先1后那么x的最终值是2符合预期。但多线程并发并不能确保对一个变量的访问在时间上完全错开 如果时间上没有完全错开假设线程1在core1上执行线程2在core2上执行那么一个可能的执行过程如下 首先线程1把x读到core1的寄存器线程2也把x的值加载到core2的寄存器此时存放在两个core的寄存器中x的副本都是0 然后线程1完成自增更新寄存器里x的值的副本0变1线程2也完成自增更新寄存器里x的值的副本0变1 然后线程1将更新后的新值1写入变量x的内存位置 最后线程2将更新后的新值1写入同一内存位置变量x的最终值是1不符合预期
线程1和线程2在同一个core上交错执行也有可能出现同样的问题这个问题跟硬件结构无关。之所以会出现不符合预期的情况主要是因为“加载更新保存”这3个步骤不能在一个内存周期内完成。多个线程对同一变量并发读写不加同步的话会出现数据不一致。
在这个例子中不一致表现为x的终值既可能为1也可能为2。
示例4
用C类模板实现一个队列
// example 4
template typename T
class Queue {static const unsigned int CAPACITY 100;T elements[CAPACITY];int num 0, head 0, tail -1;
public:// 入队bool push(const T element) {if (num CAPACITY) return false;tail (tail) % CAPACITY;elements[tail] element;num;return true;}// 出队void pop() {assert(!empty());head (head) % CAPACITY;--num;}// 判空bool empty() const { return num 0; }// 访队首const T front() const {assert(!empty());return elements[head];}
};
代码解释 T elements[]保存数据2个游标分别用于记录队首head和队尾tail的位置下标 push()接口先移动tail游标再把元素添加到队尾 pop()接口移动head游标弹出队首元素逻辑上弹出 front()接口返回队首元素的引用 front()、pop()先做断言调用pop()/front()的客户代码需确保队列非空
假设现在有一个Queueint实例q因为直接调用pop可能assert失败我们封装一个try_pop()代码如下
Queueint q;
void try_pop() {if (!q.empty()) {q.pop();}
}
如果多个线程调用try_pop()会有问题为什么
原因判空出队这2个操作不能在一个指令周期内完成。如果线程1在判断队列非空后线程2穿插进来判空也为伪这样就有可能2个线程竞争弹出唯一的元素。
多线程环境下读变量然后基于值做进一步操作这样的逻辑如果不加保护就会出错这是由数据使用方式引入的问题。
示例5
再看一个简单的简单的对int32_t多线程读写。
// example 5
int32_t data[8] {1,2,3,4,5,6,7,8}; struct Foo {int32_t get() const { return x; }void set(int32_t x) { this-x x; }int32_t x;
} foo;void thread_write1() {for (;;) { for (auto v : data) { foo.set(v); } }
}void thread_write2() {for (;;) { for (auto v : data) { foo.set(v); } }
}void thread_read() {for (;;) { printf(%d, foo.get()); }
}
Foo::get的实现有问题吗如果有问题是什么问题
示例6
看一个用数组实现FIFO队列的程序一个线程写put()一个线程读get()。
// example 6
#include iostream
#include algorithm// 用数组实现的环型队列
class FIFO {static const unsigned int CAPACITY 1024; // 容量需要满足是2^Nunsigned char buffer[CAPACITY]; // 保存数据的缓冲区unsigned int in 0; // 写入位置unsigned int out 0; // 读取位置unsigned int free_space() const { return CAPACITY - in out; }
public:// 返回实际写入的数据长度 len返回小于len时对应空闲空间不足unsigned int put(unsigned char* src, unsigned int len) {// 计算实际可写入数据长度lenlen std::min(len, free_space());// 计算从in位置到buffer结尾有多少空闲空间unsigned int l std::min(len, CAPACITY - (in (CAPACITY - 1)));// 1. 把数据放入buffer的in开始的缓冲区最多到buffer结尾memcpy(buffer (in (CAPACITY - 1)), src, l); // 2. 把数据放入buffer开头如果上一步还没有放完len - l为0代表上一步完成数据写入memcpy(buffer, src l, len - l);in len; // 修改in位置累加到达uint32_max后溢出回绕return len;}// 返回实际读取的数据长度 len返回小于len时对应buffer数据不够unsigned int get(unsigned char *dst, unsigned int len) {// 计算实际可读取的数据长度len std::min(len, in - out);unsigned int l std::min(len, CAPACITY - (out (CAPACITY - 1)));// 1. 从out位置开始拷贝数据到dst最多拷贝到buffer结尾memcpy(dst, buffer (out (CAPACITY - 1)), l);// 2. 从buffer开头继续拷贝数据如果上一步还没拷贝完len - l为0代表上一步完成数据获取memcpy(dst l, buffer, len - l);out len; // 修改out累加到达uint32_max后溢出回绕return len;}
}; kfifo 环型队列只是逻辑上的概念因为采用了数组作为数据结构所以实际物理存储上并非环型。 put()用于往队列里放数据参数srclen描述了待放入的数据信息 get()用于从队列取数据参数dstlen描述了要把数据读到哪里、以及读多少字节 capacity精心选择为2的n次方可以得到2个好处 非常技巧性的利用了无符号整型溢出回绕便于处理对in和out移动 便于计算长度通过按位与操作而不必除余 搜索kfifo获得更详细的解释 in和out是2个游标 in用来指向新写入数据的存放位置写入的时候只需要简单增加in out用来指示从buffer的什么位置读取数据的读取的时候也只需简单增加out in和out在操作上之所以能单调增加得益于上述capacity的巧妙选择 为了简化队列容量被限制为1024字节不支持扩容这不影响多线程的讨论
写的时候先写入数据再移动out游标读的时候先拷贝数据再移动in游标out游标移动后消费者才获得get到新放入数据的机会。
直觉告诉我们2个线程不加同步的并发读写会有问题但真有问题吗如果有到底有什么问题怎么解决
2.3 保护什么
多线程程序里我们要保护的是数据而非代码虽然Java等语言里有临界代码、sync方法但最终要保护的还是代码访问的数据。
2.4 串行化
如果有一个线程正在访问某共享临界资源那么在它结束访问之前其他线程不能执行访问同一资源的代码访问临界资源的代码叫临界代码其他线程想要访问同一资源则它必须等待直到那个线程访问完成它才能获得访问的机会现实中有很多这样的例子。比如高速公路上的汽车过检查站假设检查站只有一个车道则无论高速路上有多少车道过检查站的时候只能一辆车接着一辆车从单一车道鱼贯而入。
对多线程访问共享资源施加此种约束就叫串行化。
2.5 原子操作和原子变量
针对前面的两个线程对同一整型变量自增的问题如果“load、update、store”这3个步骤是不可分割的整体即自增操作x满足原子性上面的程序便不会有问题。
因为这样的话2个线程并发执行x只会有2个结果 线程a x然后线程b x结果是2 线程b x然后线程a x结果是2
除此之外不会出现第三种情况线程a、b孰先孰后取决于线程调度但不影响最终结果。
Linux操作系统和C/C编程语言都提供了整型原子变量原子变量的自增、自减等操作都是原子的操作是原子性的意味着它是一个不可细分的操作整体原子变量的用户观察它只能看到未完成和已完成2种状态看不到半完成状态。
如何保证原子性是实现层面的问题应用程序员只需要从逻辑上理解原子性并能恰当的使用它就行了。原子变量非常适用于计数、产生序列号这样的应用场景。
2.6 锁
前面举了很多例子阐述多线程不加同步并发访问数据会引起什么问题下面讲解用锁如何做同步。
2.6.1 互斥锁
针对线程1 write_msg() 线程2 read_msg()的问题如果能让线程1 write_msg()的过程中线程2不能read_msg()那就不会有问题。这个要求其实就是要让多个线程互斥访问共享资源。
互斥锁就是能满足上述要求的同步机制互斥是排他的意思它可以确保在同一时间只能有一个线程对那个共享资源进行访问。
互斥锁有且只有2种状态 已加锁locked状态 未加锁unlocked状态
互斥锁提供加锁和解锁两个接口 加锁acquire当互斥锁处于未加锁状态时则加锁成功把锁设置为已加锁状态并返回当互斥锁处于已加锁状态时那么试图对它加锁的线程会被阻塞直到该互斥量被解锁 解锁release通过把锁设置为未加锁状态释放锁其他因为申请加锁而陷入等待的线程将获得执行机会。如果有多个等待线程只有一个会获得锁而继续执行
我们为某个共享资源配置一个互斥锁使用互斥锁做线程同步那么所有线程对该资源的访问都需要遵从“加锁、访问、解锁”的三步
DataType shared_resource;
Mutex shared_resource_mutex;void shared_resource_visitor1() {// step1: 加锁shared_resource_mutex.lock();// step2: operate shared_resouce// operation1// step3: 解锁shared_resource_mutex.unlock();
}void shared_resource_visitor2() {// step1: 加锁shared_resource_mutex.lock();// step2: operate shared_resouce// operation2// step3: 解锁shared_resource_mutex.unlock();
}
假设线程1执行shared_resource_visitor1()该函数在访问数据之前申请加锁如果互斥锁已经被其他线程加锁则调用该函数的线程会阻塞在加锁操作上直到其他线程访问完数据释放解锁阻塞在加锁操作的线程1才会被唤醒并尝试加锁 如果没有其他线程申请该锁那么线程1加锁成功获得了对资源的访问权完成操作后释放锁 如果其他线程也在申请该锁那么 如果其他线程抢到了锁那么线程1继续阻塞 如果线程1抢到了该锁那么线程1将访问资源再释放锁其他竞争该锁的线程得以有机会继续执行
如果不能承受加锁失败而陷入阻塞的代价可以调用互斥量的try_lock()接口它在加锁失败后会立即返回。
注意在访问资源前申请锁访问后释放锁是一个编程契约通过遵守契约而获得数据一致性的保障它并非一种硬性的限制即如果别的线程遵从三步曲而另一个线程不遵从这种约定代码能通过编译且程序能运行但结果可能是错的。
2.6.2 读写锁
读写锁跟互斥锁类似也是申请锁的时候如果不能得到满足则阻塞但读写锁跟互斥锁也有不同读写锁有3个状态 已加读锁状态 已加写锁状态 未加锁状态
对应3个状态读写锁有3个接口加读锁加写锁解锁 加读锁如果读写锁处于已加写锁状态则申请锁的线程阻塞否则把锁设置为已加读锁状态并成功返回 加写锁如果读写锁处于未加锁状态则把锁设置为已加写锁状态并成功返回否则阻塞 解锁把锁设置为未加锁状态后返回
读写锁提升了线程的并行度可以提升吞吐。它可以让多个读线程同时读共享资源而写线程访问共享资源的时候其他线程不能执行所以读写锁适合对共享资源访问“读大于写”的场合。读写锁也叫“共享互斥锁”多个读线程可以并发访问同一资源这对应共享的概念而写线程是互斥的写线程访问资源的时候其他线程无论读写都不可以进入临界代码区。
考虑一个场景如果有线程1、2、3共享资源x读写锁rwlock保护资源线程1读访问某资源然后线程2以写的形式访问同一资源x因为rwlock已经被加了读锁所以线程2被阻塞然后过了一段时间线程3也读访问资源x这时候线程3可以继续执行因为读是共享的然后线程1读访问完成线程3继续访问过了一段时间在线程3访问完成前线程1又申请读资源那么它还是会获得访问权但是写资源的线程2会一直被阻塞。
为了避免共享的读线程饿死写线程通常读写锁的实现会给写线程优先权当然这处决于读写锁的实现作为读写锁的使用方理解它的语义和使用场景就够了。
2.6.3 自旋锁
自旋锁Spinlock的接口跟互斥量差不多但实现原理不同。线程在acquire自旋锁失败的时候它不会主动让出CPU从而进入睡眠状态而是会忙等它会紧凑的执行测试和设置(Test-And-Set)指令直到TAS成功否则就一直占着CPU做TAS。
自旋锁对使用场景有一些期待它期待acquire自旋锁成功后很快会release锁线程运行临界区代码的时间很短访问共享资源的逻辑简单这样的话别的acquire自旋锁的线程只需要忙等很短的时间就能获得自旋锁从而避免被调度走陷入睡眠它假设自旋的成本比调度的低它不愿耗费时间在线程调度上线程调度需要保存和恢复上下文需要耗费CPU。
内核态线程很容易满足这些条件因为运行在内核态的中断处理函数里可以通过关闭调度从而避免CPU被抢占而且有些内核态线程调用的处理函数不能睡眠只能使用自旋锁。
而运行在用户态的应用程序则推荐使用互斥锁等睡眠锁。因为运行在用户态应用程序虽然很容易满足临界区代码简短但持有锁时间依然可能很长。在分时共享的多任务系统上、当用户态线程的时间配额耗尽或者在支持抢占式的系统上、有更高优先级的任务就绪那么持有自旋锁的线程就会被系统调度走这样持有锁的过程就有可能很长而忙等自旋锁的其他线程就会白白消耗CPU资源这样的话就跟自旋锁的理念相背。
Linux系统优化过后的mutex实现在加锁的时候会先做有限次数的自旋只有有限次自旋失败后才会进入睡眠让出CPU所以实际使用中它的性能也足够好。此外自旋锁必须在多CPU或者多Core架构下试想如果只有一个核那么它执行自旋逻辑的时候别的线程没有办法运行也就没有机会释放锁。
2.6.4 锁的粒度
合理设置锁的粒度粒度太大会降低性能太小会增加代码编写复杂度。
2.6.5 锁的范围
锁的范围要尽量小最小化持有锁的时间。
2.6.6 死锁
程序出现死锁有两种典型原因
ABBA锁
假设程序中有2个资源X和Y分别被锁A和B保护线程1持有锁A后想要访问资源Y而访问资源Y之前需要申请锁B而如果线程2正持有锁B并想要访问资源X为了访问资源X所以线程2需要申请锁A。线程1和线程2分别持有锁A和B并都希望申请对方持有的锁因为线程申请对方持有的锁得不到满足所以便会陷入等待也就没有机会释放自己持有的锁对方执行流也就没有办法继续前进导致相持不下无限互等进而死锁。
上述的情况似乎很明显但如果代码量很大有时候这种死锁的逻辑不会这么浅显它被复杂的调用逻辑所掩盖但抽茧剥丝最根本的逻辑就是上面描述的那样。这种情况叫ABBA锁既某个线程持有A锁申请B锁而另一个线程持有B锁申请A锁。这种情况可以通过try lock实现尝试获取锁如果不成功则释放自己持有的锁而不一根筋下去。另一种解法就是锁排序对A/B两把锁的加锁操作都遵从同样的顺序比如先A后B也能避免死锁。
自死锁
对于不支持重复加锁的锁如果线程持有某个锁而后又再次申请锁因为该锁已经被自己持有再次申请锁必然得不到满足从而导致死锁。
2.7 条件变量
条件变量常用于生产者消费者模式需配合互斥量使用。
假设你要编写一个网络处理程序I/O线程从套接字接收字节流反序列化后产生一个个消息自定义协议然后投递到一个消息队列一组工作线程负责从消息队列取出并处理消息。这是典型的生产者-消费者模式I/O线程生产消息往队列putWork线程消费消息从队列getI/O线程和Work线程并发访问消息队列显然消息队列是竞争资源需要同步。 proceduer-consumer 可以给队列配置互斥锁put和get操作前都先加锁操作完成再解锁。代码差不多是这样的
void io_thread() {while (1) {Msg* msg read_msg_from_socket();msg_queue_mutex.lock();msg_queue.put(msg);msg_queue_mutex.unlock();}
}void work_thread() {while (1) {msg_queue_mutex.lock();Msg* msg msg_queue.get();msg_queue_mutex.unlock();if (msg ! nullptr) {process(msg);}}
}
work线程组的每个线程都忙于检查消息队列是否有消息如果有消息就取一个出来然后处理消息如果没有消息就在循环里不停检查这样的话即使负载很轻但work_thread还是会消耗大量的CPU时间。
我们当然可以在两次查询之间加入短暂的sleep从而让出cpu但是这个睡眠的时间设置为多少合适呢设置长了的话会出现消息到来得不到及时处理延迟上升设置太短了还是无辜消耗了CPU资源这种不断问询的方式在编程上叫轮询。
轮询行为逻辑上相当于你在等一个投递到楼下小邮局的包裹你下楼查验没有之后就上楼回房间然后又下楼查验你不停的上下楼查验其实大可不必如此何不等包裹到达以后让门卫打电话通知你去取呢
条件变量提供了一种类似通知notify的机制它让两类线程能够在一个点交汇。条件变量能够让线程等待某个条件发生条件本身受互斥锁保护因此条件变量必须搭配互斥锁使用锁保护条件线程在改变条件前先获得锁然后改变条件状态再解锁最后发出通知等待条件的睡眠中的线程在被唤醒前必须先获得锁再判断条件状态如果条件不成立则继续转入睡眠并释放锁。
对应到上面的例子工作线程等待的条件是消息队列有消息非空用条件变量改写上面的代码
void io_thread() {while (1) {Msg* msg read_msg_from_socket();{std::lock_guardstd::mutex lock(msg_queue_mutex);msg_queue.push_back(msg);}msg_queue_not_empty.notify_all();}
}void work_thread() {while (1) {Msg* msg nullptr;{std::unique_lockstd::mutex lock(msg_queue_mutex);msg_queue_not_empty.wait(lock, []{ return !msg_queue.empty(); });msg msg_queue.get();}process(msg);}
}
std::lock_guard是互斥量的一个RAII包装类std::unique_lock除了会在析构函数自动解锁外还支持主动unlock()。
生产者在往msg_queue投递消息的时候需要对msg_queue加锁通知work线程的代码可以放在解锁之后等待msg_queue_not_empty条件必须受msg_queue_mutex保护wait的第二个参数是一个lambda表达式因为会有多个work线程被唤醒线程被唤醒后会重新获得锁检查条件如果不成立则再次睡眠。条件变量的使用需要非常谨慎否则容易出现不能唤醒的情况。
C语言的条件变量、Posix条件变量的编程接口跟C的类似概念上是一致的故在此不展开介绍。
2.8 lock-free和无锁数据结构
2.8.1 锁同步的问题
线程同步分为阻塞型同步和非阻塞型同步。 互斥量、信号、条件变量这些系统提供的机制都属于阻塞型同步在争用资源的时候会导致调用线程阻塞 非阻塞型同步是指在无锁的情况下通过某种算法和技术手段实现不用阻塞而同步
锁是阻塞同步机制阻塞同步机制的缺陷是可能挂起你的程序如果持有锁的线程崩溃或者hang住则锁永远得不到释放而其他线程则将陷入无限等待另外它也可能导致优先级倒转等问题。所以我们需要lock-free这类非阻塞的同步机制。
2.8.2 什么是lock-free
lock-free没有锁同步的问题所有线程无阻碍的执行原子指令而不是等待。比如一个线程读atomic类型变量一个线程写atomic变量它们没有任何等待硬件原子指令确保不会出现数据不一致写入数据不会出现半完成读取数据也不会读一半。
那到底什么是lock-free有人说lock-free就是不使用mutex / semaphores之类的无锁lock-Less编程这句话严格来说并不对。
我们先看一下wiki对Lock-free的描述: Lock-freedom allows individual threads to starve but guarantees system-wide throughput. An algorithm is lock-free if, when the program threads are run for a sufficiently long time, at least one of the threads makes progress (for some sensible definition of progress). All wait-free algorithms are lock-free. In particular, if one thread is suspended, then a lock-free algorithm guarantees that the remaining threads can still make progress. Hence, if two threads can contend for the same mutex lock or spinlock, then the algorithm is not lock-free. (If we suspend one thread that holds the lock, then the second thread will block.) 翻译一下 第1段lock-free允许单个线程饥饿但保证系统级吞吐。如果一个程序线程执行足够长的时间那么至少一个线程会往前推进那么这个算法就是lock-free的 第2段尤其是如果一个线程被暂停lock-free算法保证其他线程依然能够往前推进
第1段给lock-free下定义第2段则是对lock-free作解释如果2个线程竞争同一个互斥锁或者自旋锁那它就不是lock-free的因为如果暂停Hang持有锁的线程那么另一个线程会被阻塞。
wiki的这段描述很抽象它不够直观稍微再解释一下lock-free描述的是代码逻辑的属性不使用锁的代码大部分具有这种属性。大家经常会混淆这lock-free和无锁这2个概念。其实lock-free是对代码算法性质的描述是属性而无锁是说代码如何实现是手段。
lock-free的关键描述是如果一个线程被暂停那么其他线程应能继续前进它需要有系统级system-wide的吞吐。
如图两个线程在时间线上至少有一个线程处于running状态。 lock-free 我们从反面举例来看假设我们要借助锁实现一个无锁队列我们可以直接使用线程不安全的std::queue std::mutex来做
template typename T
class Queue {
public:void push(const T t) {q_mutex.lock();q.push(t);q_mutex.unlock();}
private:std::queueT q;std::mutex q_mutex;
};
如果有线程A/B/C同时执行push方法最先进入的线程A获得互斥锁。线程B和C因为获取不到互斥锁而陷入等待。这个时候线程A如果因为某个原因如出现异常或者等待某个资源而被永久挂起那么同样执行push的线程B/C将被永久挂起系统整体system-wide没法推进而这显然不符合lock-free的要求。因此所有基于锁包括spinlock的并发实现都不是lock-free的。
因为它们都会遇到同样的问题即如果永久暂停当前占有锁的线程/进程的执行将会阻塞其他线程/进程的执行。而对照lock-free的描述它允许部分process理解为执行流饿死但必须保证整体逻辑的持续前进基于锁的并发显然是违背lock-free要求的。
2.8.3 CAS loop实现lock-free
Lock-Free同步主要依靠CPU提供的read-modify-write原语著名的“比较和交换”CASCompare And Swap在X86机器上是通过cmpxchg系列指令实现的原子操作CAS逻辑上用代码表达是这样的
bool CAS(T* ptr, T expect_value, T new_value) {if (*ptr ! expect_value) {return false;}*ptr new_value;return true;
}
CAS接受3个参数 内存地址 期望值通常传第一个参数所指内存地址中的旧值 新值
逻辑描述CAS比较内存地址中的值和期望值如果不相同就返回失败如果相同就将新值写入内存并返回成功。
当然这个C函数描述的只是CAS的逻辑这个函数操作不是原子的因为它可以划分成几个步骤读取内存值、判断、写入新值各步骤之间是可以插入其他操作的。不过前面讲了原子指令相当于把这些步骤打包它可能是通过lock; cmpxchg指令实现的但那是实现细节程序员更应该注重在逻辑上理解它的行为。
通过CAS实现Lock-free的代码通常借助循环代码如下
do {T expect_value *ptr;
} while (!CAS(ptr, expect_value, new_value)); 创建共享数据的本地副本expect_value 根据需要修改本地副本从ptr指向的共享数据里load后赋值给expect_value 检查共享的数据跟本地副本是否相等如果相等则把新值复制到共享数据
第三步是关键虽然CAS是原子的但加载expect_value跟CAS这2个步骤并不是原子的。所以我们需要借助循环如果ptr内存位置的值没有变*ptr expect_value那就存入新值返回成功否则说明加载expect_value后ptr指向的内存位置被其他线程修改了这时候就返回失败重新加载expect_value重试直到成功为止。
CAS loop支持多线程并发写这个特点太有用了因为多线程同步很多时候都面临多写的问题我们可以基于cas实现Fetch-and-add(FAA)算法它看起来像这样
T faa(T t) {T temp t;while (!compare_and_swap(x, temp, temp 1));
}
第一步加载共享数据的值到temp第二步比较存入新值直到成功。
2.8.4无锁数据结构lock-free stack
无锁数据结构是通过非阻塞算法而非锁保护共享数据非阻塞算法保证竞争共享资源的线程不会因为互斥而让它们的执行无限期暂停无阻塞算法是lock-free的因为无论如何调度都能确保有系统级的进度。wiki定义如下 A non-blocking algorithm ensures that threads competing for a shared resource do not have their execution indefinitely postponed by mutual exclusion. A non-blocking algorithm is lock-free if there is guaranteed system-wide progress regardless of scheduling. 下面是C atomic compare_exchange_weak()实现的一个lock-free堆栈from CppReference
template typename T
struct node {T data;node* next;node(const T data) : data(data), next(nullptr) {}
};template typename T
class stack {std::atomicnodeT* head;
public:void push(const T data) {nodeT* new_node new nodeT(data);new_node-next head.load(std::memory_order_relaxed);while (!head.compare_exchange_weak(new_node-next, new_node,std::memory_order_release,std::memory_order_relaxed));}
};
代码解析 节点node保存T类型的数据data并且持有指向下一个节点的指针 std::atomicnodeT*类型表明atomic里放置的是Node的指针而非Node本身因为指针在64位系统上是8字节等于机器字长再长没法保证原子性 stack类包含head成员head是一个指向头结点的指针头结点指针相当于堆顶指针刚开始没有节点head为NULL push函数里先根据data值创建新节点然后要把它放到堆顶 因为是用链表实现的栈所以如果新节点要成为新的堆顶相当于新节点作为新的头结点插入那么新节点的next域要指向原来的头结点并让head指向新节点 new_node-next head.load把新节点的next域指向原头结点然后head.compare_exchange_weak(new_node-next, new_node)让head指向新节点 C atomic的compare_exchange_weak()跟上述的CAS稍有不同head.load()不等于new_node-next的时候它会把head.load()的值重新加载到new_node-next 所以在加载head值和cas之间如果其他线程调用push操作改变了head的值那没有关系该线程的本次cas失败下次重试便可以了 多个线程同时push时任一线程在任意步骤阻塞/挂起其他线程都会继续执行并最终返回无非就是多执行几次while循环
这样的行为逻辑显然符合lock-free的定义注意用casloop实现自旋锁不符合lock-free的定义注意区分。
2.9 程序序Program Order
对单线程程序而言代码会一行行顺序执行就像我们编写的程序的顺序那样。比如
a 1;
b 2;
会先执行a1再执行b2从程序角度看到的代码行依次执行叫程序序我们在此基础上构建软件并以此作为讨论的基础。
2.10 内存序Memory Order
与程序序相对应的内存序是指从某个角度观察到的对于内存的读和写所真正发生的顺序。内存操作顺序并不唯一在一个包含core0和core1的CPU中core0和core1有着各自的内存操作顺序这两个内存操作顺序不一定相同。从包含多个Core的CPU的视角看到的全局内存操作顺序跟单core视角看到的内存操作顺序亦不同而这种不同对于有些程序逻辑而言是不可接受的例如
程序序要求a 1在b 2之前执行但内存操作顺序可能并非如此对a赋值1并不确保发生在对b赋值2之前这是因为 如果编译器认为对b赋值没有依赖对a赋值那它完全可能在编译期调整编译后的汇编指令顺序 即使编译器不做调整到了执行期也有可能对b的赋值先于对a赋值执行
虽然对一个Core而言如上所述这个Core观察到的内存操作顺序不一定符合程序序但内存操作序和程序序必定产生相同的结果无论在单Core上对a、b的赋值哪个先发生结果上都是a被赋值为1、b被赋值为2如果单核上乱序执行会影响结果那编译器的指令重排和CPU乱序执行便不会发生硬件会提供这项保证。
但多核系统硬件不提供这样的保证多线程程序中每个线程所工作的Core观察到的不同内存操作序以及这些顺序与全局内存序的差异常常导致多线程同步失败所以需要有同步机制确保内存序与程序序的一致内存屏障Memory Barrier的引入就是为了解决这个问题它让不同的Core之间以及Core与全局内存序达成一致。
2.11 乱序执行Out-of-order Execution
乱序执行会引起内存顺序跟程序顺序不同乱序执行的原因是多方面的比如编译器指令重排、超标量指令流水线、预测执行、Cache-Miss等。内存操作顺序无法精确匹配程序顺序这有可能带来混乱既然有副作用那为什么还需要乱序执行呢答案是为了性能。
我们先看看没有乱序执行之前早期的有序处理器In-order Processors是怎么处理指令的 指令获取从代码节内存区域加载指令到I-Cache 译码 如果指令操作数可用例如操作数位于寄存器中则分发指令到对应功能模块中如果操作数不可用通常是需要从内存加载则处理器会stall一直等到它们就绪直到数据被加载到cache或拷贝进寄存器 指令被功能单元执行 功能单元将结果写回寄存器或内存位置
乱序处理器Out-of-order Processors又是怎么处理指令的呢 指令获取从代码节内存区域加载指令到I-Cache 译码 分发指令到指令队列 指令在指令队列中等待一旦操作数就绪指令就离开指令队列那怕它之前的指令未被执行乱序 指令被派往功能单元并被执行 执行结果放入队列Store Buffer而不是直接写入Cache 只有更早请求执行的指令结果写入cache后指令执行结果才写入Cache通过对指令结果排序写入cache使得执行看起来是有序的
指令乱序执行是结果但原因并非只有CPU的乱序执行而是由两种因素导致 编译期指令重排编译器编译器会为了性能而对指令重排源码上先后的两行被编译器编译后可能调换指令顺序但编译器会基于一套规则做指令重排有明显依赖的指令不会被随意重排指令重排不能破坏程序逻辑 运行期乱序执行CPUCPU的超标量流水线、以及预测执行、Cache-Miss等都有可能导致指令乱序执行也就是说后面的指令有可能先于前面的指令执行
2.12 Store Buffer
为什么需要Store Buffer
考虑下面的代码
void set_a() {a 1;
} 假设运行在core0上的set_a()对整型变量a赋值1计算机通常不会直接写穿通到内存而是会在Cache中修改对应Cache Line 如果Core0的Cache里没有a赋值操作store会造成Cache Miss Core0会stall在等待Cache就绪从内存加载变量a到对应的Cache Line但Stall会损害CPU性能相当于CPU在这里停顿白白浪费着宝贵的CPU时间 有了Store Buffer当变量在Cache中没有就位的时候就先Buffer住这个Store操作而Store操作一旦进入Store Buffercore便认为自己Store完成当随后Cache就位store会自动写入对应cache。
所以我们需要Store Buffer每个Core都有独立的Store Buffer每个Core都访问私有的Store BufferStore Buffer帮助CPU遮掩了Store操作带来的延迟。
Store Buffer会带来什么问题
a 1;
b 2;
assert(a 1);
上面的代码断言a1的时候需要读load变量a的值而如果a在被赋值前就在Cache中就会从Cache中读到a的旧值可能是1之外的其他值所以断言就可能失败。但这样的结果显然是不能接受的它违背了最直观的程序顺序性。
问题出在变量a除保存在内存外还有2份拷贝一份在Store Buffer里一份在Cache里如果不考虑这2份拷贝的关系就会出现数据不一致。那怎么修复这个问题呢
可以通过在Core Load数据的时候先检查Store Buffer中是否有悬而未决的a的新值如果有则取新值否则从cache取a的副本。这种技术在多级流水线CPU设计的时候就经常使用叫Store Forwarding。有了Store Buffer Forwarding就能确保单核程序的执行遵从程序顺序性但多核还是有问题让我们考查下面的程序
多核内存序问题
int a 0; // 被CPU1 Cache
int b 0; // 被CPU0 Cache// CPU0执行
void x() {a 1;b 2;
}// CPU1执行
void y() {while (b 0);assert(a 1);
}
假设a和b都被初始化为0CPU0执行x()函数CPU1执行y()函数变量a在CPU1的local Cache里变量b在CPU0的local Cache里。 CPU0执行a 1的时候因为a不在CPU0的local cacheCPU0会把a的新值1写入Store Buffer里并发送Read Invalidate消息给其他CPU CPU1执行while (b 0)因为b不在CPU1的local cache里CPU1会发送Read消息去其他CPU获取b的值 CPU0执行b 2因为b在CPU0的local Cache所以直接更新local cache中b的副本 CPU0收到CPU1发来的read消息把b的新值2发送给CPU1同时存放b的Cache Line的状态被设置为Shared以反应b同时被CPU0和CPU1 cache住的事实 CPU1收到b的新值2后结束循环继续执行assert(a 1)因为此时local Cache中的a值为0所以断言失败 CPU1收到CPU0发来的Read Invalidate后更新a的值为1但为时已晚程序在上一步已经崩了assert失败
怎么办答案留到内存屏障一节揭晓。
2.13 Invalidate Queue
为什么需要Invalidate Queue
当一个变量加载到多个core的Cache则这个Cache Line处于Shared状态如果Core1要修改这个变量则需要通过发送核间消息Invalidate来通知其他Core把对应的Cache Line置为Invalid当其他Core都Invalid这个CacheLine后则本Core获得该变量的独占权这个时候就可以修改它了。
收到Invalidate消息的core需要回Invalidate ACK一个个core都这样ACK等所有core都回复完Core1才能修改它这样CPU就白白浪费。
事实上其他核在收到Invalidate消息后会把Invalidate消息缓存到Invalidate Queue并立即回复ACK真正Invalidate动作可以延后再做这样一方面因为Core可以快速返回别的Core发出的Invalidate请求不会导致发生Invalidate请求的Core不必要的Stall另一方面也提供了进一步优化可能比如在一个CacheLine里的多个变量的Invalidate可以攒一次做了。
但写Store Buffer的方式其实是Write Invalidate它并非立即写入内存如果其他核此时从内存读数则有可能不一致。
2.14 内存屏障
那有没有方法确保对a的赋值一定先于对b的赋值呢有内存屏障被用来提供这个保障。
内存屏障Memory Barrier也称内存栅栏、屏障指令等是一类同步屏障指令是CPU或编译器在对内存随机访问的操作中的一个同步点同步点之前的所有读写操作都执行后才可以开始执行此点之后的操作。语义上内存屏障之前的所有写操作都要写入内存内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。
内存屏障其实就是提供一种机制确保代码里顺序写下的多行会按照书写的顺序被存入内存主要是解决Store Buffer引入导致的写入内存间隙的问题。
void x() {a 1;wmb();b 2;
}
像上面那样在a1和b2之间插入一条内存屏障语句就能确保a1先于b2生效从而解决了内存乱序访问问题那插入的这句smp_mb()到底会干什么呢
回忆前面的流程CPU0在执行完a 1之后执行smp_mb()操作这时候它会给Store Buffer里的所有数据项做一个标记marked然后继续执行b 2但这时候虽然b在自己的cache里但由于store buffer里有marked条目所以CPU0不会修改cache中的b而是把它写入Store Buffer所以CPU0收到Read消息后会把b的0值发给CPU1所以继续在while (b)自旋。
简而言之Core执行到write memory barrierwmb的时候如果Store Buffer还有悬而未决的store操作则都会被mark上直到被标注的Store操作进入内存后后续的Store操作才能被执行因此wmb保障了barrier前后操作的顺序它不关心barrier前的多个操作的内存序以及barrier后的多个操作的内存序是否与Global Memory Order一致。
a 1;
b 2;
wmb();
c 3;
d 4;
wmb()保证“a1;b2”发生在“c3;d 4”之前不保证a 1和b 2的内存序也不保证c 3和d 4的内部序。
Invalidate Queue的引入的问题
就像引入Store Buffer会影响Store的内存一致性Invalidate Queue的引入会影响Load的内存一致性。因为Invalidate queue会缓存其他核发过来的消息比如Invalidate某个数据的消息被delay处置导致core在Cache Line中命中这个数据而这个Cache Line本应该被Invalidate消息标记无效。如何解决这个问题呢 一种思路是硬件确保每次load数据的时候需要确保Invalidate Queue被清空这样可以保证load操作的强顺序 软件的思路就是仿照wmb()的定义加入rmb()约束。rmb()给我们的invalidate queue加上标记。当一个load操作发生的时候之前的rmb()所有标记的invalidate命令必须全部执行完成然后才可以让随后的load发生。这样我们就在rmb()前后保证了load观察到的顺序等同于global memory order
所以我们可以像下面这样修改代码
a 1;
wmb();
b 2;
while(b ! 2) {};
rmb();
assert(a 1);
系统对内存屏障的支持
gcc编译器在遇到内嵌汇编语句asm volatile( ::: memory)将以此作为一条内存屏障重排序内存操作即此语句之前的各种编译优化将不会持续到此语句之后。
Linux 内核提供函数 barrier()用于让编译器保证其之前的内存访问先于其之后的完成。
#define barrier() __asm__ __volatile__( ::: memory)
CPU内存屏障 通用barrier保证读写操作有序 mb()和smp_mb() 写操作barrier仅保证写操作有序wmb()和smp_wmb() 读操作barrier仅保证读操作有序rmb()和smp_rmb()
小结 为了提高处理器的性能SMP中引入了store buffer(以及对应实现store buffer forwarding)和invalidate queue store buffer的引入导致core上的store顺序可能不匹配于global memory的顺序对此我们需要使用wmb()来解决 invalidate queue的存在导致core上观察到的load顺序可能与global memory order不一致对此我们需要使用rmb()来解决 由于wmb()和rmb()分别只单独作用于store buffer和invalidate queue因此这两个memory barrier共同保证了store/load的顺序
3 伪共享
多个线程同时读写同一个Cache Line中的变量、导致CPU Cache频繁失效从而使得程序性能下降的现象称为伪共享False Sharing。
const size_t shm_size 16*1024*1024; //16M
static char shm[shm_size];
std::atomicsize_t shm_offset{0};void f() {for (;;) {auto off shm_offset.fetch_add(sizeof(long));if (off shm_size) break;*(long*)(shm off) off; // 赋值}
}
考察上面的程序shm是一块16M字节的内存我测试的机器的L3 Cache是32M16M字节能确保shm在Cache里放得下。f()函数的循环里视shm为long类型的数组依次给每个元素赋值shm_offset用于记录偏移位置shm_offset.fetch_add(sizeof(long))原子性的增加shm_offset的值因为x86_64系统上long的长度为8所以shm_offset每次增加8并返回增加前的值对shm上long数组的每个元素赋值后结束循环从函数返回。
因为shm_offset是atomic类型变量所以多线程调用f()依然能正常工作虽然多个线程会竞争shm_offset但每个线程会排他性的对各long元素赋值多线程并行会加快对shm的赋值操作。我们加上多线程调用代码
std::atomicsize_t step{0};const int THREAD_NUM 2;void work_thread() {const int LOOP_N 10;for (int n 1; n LOOP_N; n) {f();step;while (step.load() n * THREAD_NUM) {}shm_offset 0;}
}int main() {std::thread threads[THREAD_NUM];for (int i 0; i THREAD_NUM; i) {threads[i] std::move(std::thread(work_thread));}for (int i 0; i THREAD_NUM; i) {threads[i].join();}return 0;
} main函数里启动2个工作线程work_thread 工作线程对shm共计赋值10轮后面的每一轮会访问Cache里的shm数据step用于work_thread之间每一轮的同步 工作线程调用完f()后会增加step等2个工作线程都调用完之后step的值增加到n * THREAD_NUM后while()会结束循环重置shm_offset重新开始新一轮对shm的赋值
如图所示 false-sharing-1 编译后执行上面的程序产生如下的结果
time ./a.outreal 0m3.406s
user 0m6.740s
sys 0m0.040s
time命令用于时间测量a.out程序运行完成后会打印耗时real列显式耗时3.4秒。
3.1 改进版f_fast
我们稍微修改一下f函数改进版f函数取名f_fast
void f_fast() {for (;;) {const long inner_loop 16;auto off shm_offset.fetch_add(sizeof(long) * inner_loop);for (long j 0; j inner_loop; j) {if (off shm_size) return;*(long*)(shm off) j;off sizeof(long);}}
}
for循环里shm_offset不再是每次增加8字节sizeof(long)而是8*16128字节然后在内层的循环里依次对16个long连续元素赋值然后下一轮循环又再次增加128字节直到完成对shm的赋值。如图所示 no-false-sharing 编译后重新执行程序结果显示耗时降低到0.06秒对比前一种耗时3.4秒f_fast性能提升明显。
time ./a.outreal 0m0.062s
user 0m0.110s
sys 0m0.012s
f和f_fast的行为差异
shm数组总共有2M个long元素因为16M / sizeof(long) 得 2M f()函数行为逻辑 线程1和线程2的work_thread里会交错地对shm元素赋值shm的2M个long元素会顺序的一个接一个的派给2个线程去赋值 可能的行为元素1由线程1赋值元素2由线程2赋值然后元素3和元素4由线程1赋值然后元素5又由线程2赋值... 每次分派元素的时候shm_offset都会atomic的增加8字节所以不会出现2个线程给同1个元素赋值的情况 f_fast()函数行为逻辑 每次派元素的时候shm_offset原子性的增加128字节16个元素 这16个字节作为一个整体派给线程1或者线程2虽然线程1和线程2还是会交错的操作shm元素但是以16个元素128字节为单元这16个连续的元素不会被分开派发给不同线程 一次派发的16个元素会在一个线程里被一个接着一个的赋值内部循环里
3.2 为什么f_fast更快
第一眼感觉是f_fast()里shm_offset.fetch_add()调用频次降低到了原来的1/16有理由怀疑是原子变量的竞争减少导致程序执行速度加快。为了验证让我们在内层的循环里加一个原子变量test的fetch_addtest原子变量的竞争会像f()函数里shm_offset.fetch_add()一样激烈修改后的f_fast代码变成下面这样
void f_fast() {for (;;) {const long inner_loop 16;auto off shm_offset.fetch_add(sizeof(long) * inner_loop);for (long j 0; j inner_loop; j) {test.fetch_add(1);if (off shm_size) return;*(long*)(shm off) j;off sizeof(long);}}
}
为了避免test.fetch_add(1)的调用被编译器优化掉我们在main函数的最后把test的值打印出来。编译后测试一下结果显示执行时间只是稍微增加到real 0m0.326s很显然并不是atomic的调用频次减少导致性能飙升。
重新审视f()循环里的逻辑f()循环里的操作很简单原子增加、判断、赋值。我们把f()的里赋值注释掉再测试一下发现它的速度得到了很大提升看来是*(long*)(shm off) off这一行代码执行慢但这明明只是一行赋值。我们把它反汇编来看它只是一个mov指令源操作数是寄存器目标操作数是内存地址从寄存器拷贝数据到一个内存地址为什么会这么慢呢
3.3 原因
现在揭晓答案导致f()性能底下的元凶是伪共享false sharing。那什么是伪共享要说清这个问题还得联系CPU的架构以及CPU怎么访问数据回顾一下关于多核Cache结构。
背景知识
现代CPU可以有多个核每个核有自己的L1-L2缓存L1又区分数据缓存L1-DCache和指令缓存L1-ICacheL2不区分数据和指令Cache而L3是跨核共享的L3通过内存总线连接到内存内存被所有CPU所有Core共享。
CPU访问L1 Cache的速度大约是访问内存的100倍Cache作为CPU与内存之间的缓存减少对内存的访问频率。
从内存加载数据到Cache的时候是以Cache Line为长度单位的Cache Line的长度通常是64字节所以那怕只读一个字节但是包含该字节的整个Cache Line都会被加载到缓存同样如果修改一个字节那么最终也会导致整个Cache Line被冲刷到内存。
如果一块内存数据被多个线程访问假设多个线程在多个Core上并行执行那么它便会被加载到多个Core的的Local Cache中这些线程在哪个Core上运行就会被加载到哪个Core的Local Cache中所以内存中的一个数据在不同Core的Cache里会同时存在多份拷贝。
那么便会存在缓存一致性问题。当一个Core修改其缓存中的值时其他Core不能再使用旧值。该内存位置将在所有缓存中失效。此外由于缓存以缓存行而不是单个字节的粒度运行因此整个缓存行将在所有缓存中失效。如果我们修改了Core1缓存里的某个数据则该数据所在的Cache Line的状态需要同步给其他Core的缓存Core之间可以通过核间消息同步状态比如通过发送Invalidate消息给其他核接收到该消息的核会把对应Cache Line置为无效然后重新从内存里加载最新数据。
当然被加载到多个Core缓存中的同一Cache Line会被标记为共享Shared状态对共享状态的缓存行进行修改需要先获取缓存行的修改权独占MESI协议用来保证多核缓存的一致性更多的细节可以参考MESI的文章。
示例分析
假设线程1运行在Core1线程2运行在Core2。 因为shm被线程1和线程2这两个线程并发访问所以shm的内存数据会以Cache Line粒度被同时加载到2个Core的Cache因为被多核共享所以该Cache Line被标注为Shared状态 假设线程1在offset为64的位置写入了一个8字节的数据sizeof(long)要修改一个状态为Shared的Cache LineCore1会发送核间通信消息到Core2去拿到该Cache Line的独占权在这之后Core1才能修改Local Cache 线程1执行完shm_offset.fetch_add(sizeof(long))后shm_offset会增加到72 这时候Core2上运行的线程2也会执行shm_offset.fetch_add(sizeof(long))它返回72并将shm_offset增加到80 线程2接下来要修改shm[72]的内存位置因为shm[64]和shm[72]在一个Cache Line而这个Cache Line又被置为Invalidate所以它需要从内存里重新加载这一个Cache Line而在这之前Core1上的线程1需要把Cache Line冲刷到内存这样线程2才能加载最新的数据
这种交替执行模式相当于Core1和Core2之间需要频繁的发送核间消息收到消息的Core的Cache Line被置为无效并重新从内存里加载数据到Cache每次修改后都需要把Cache中的数据刷入内存这相当于废弃掉了Cache因为每次读写都直接跟内存打交道Cache的作用不复存在这就是性能低下的原因。
这种多核多线程程序因为并发读写同一个Cache Line的数据临近位置的内存数据导致Cache Line的频繁失效内存的频繁Load/Store从而导致性能急剧下降的现象叫伪共享伪共享是性能杀手。
3.4 另一个伪共享的例子
假设线程x和y分别修改Data的a和b变量如果被频繁调用也会出现性能低下的情况怎么规避呢
struct Data {int a;int b;
} data; // globalvoid thread1() {data.a 1;
}void thread2() {data.b 2;
}
空间换时间
避免Cache伪共享导致性能下降的思路是用空间换时间通过增加填充让a和b两个变量分布到不同的Cache Line这样对a和b的修改就会作用于不同Cache Line就能避免Cache失效的问题。
struct Data {int a;int padding[60];int b;
};
在Linux kernel中存在__cacheline_aligned_in_smp宏定义用于解决false sharing问题。
#ifdef CONFIG_SMP
#define __cacheline_aligned_in_smp __cacheline_aligned
#else
#define __cacheline_aligned_in_smp
#endifstruct Data {int a;int b __cacheline_aligned_in_smp;
};从上面的宏定义可以看到 在多核系统里该宏定义是 __cacheline_aligned也就是Cache Line的大小 在单核系统里该宏定义是空的
4 小结
pthread接口提供的几种同步原语如下 同步原语 出现背景 常见应用场景 优势 劣势 备注 互斥锁mutex 每次只有一个线程可以向前执行 大部分场景 使用简单、锁竞争不激烈的时候性能非常高 竞争激烈时候性能较低 第一选择pthread有多种锁定特性建议只使用标准互斥类型 读写锁 read_write_lock 允许更高的并行度一次只有一个线程可以占有写模式的锁但是多个读线程可以占用读模式的读写锁 适用于读的次数远大于写的情况 读多写少的情况下性能较好 可能导致写饥饿、读饥饿。取决于实现 可以实现为读优先、写优先、公平。 条件变量condition_variable 允许线程以无竞争的方式等到特定的条件发生、同时避免忙等待拥有通知机制。 生产者-消费者模型 可以用于通知线程条件满足 唤醒线程、重新获取锁和重新检查条件可能导致额外的性能开销。 需要配合互斥量使用、需要check虚假唤醒和唤醒多个posix标准允许唤醒一个以上线程 自旋锁spin 线程并不希望在重新调度上花时间。不通过休眠使线程阻塞而是通过忙等近似阻塞用来实现一些其他类型的锁 锁被持有的时间短 非抢占式内核避免中断、一些系统函数和系统库使用 自旋的时间可能会比预期长时间片作用下 用户层不推荐使用因为互斥锁足够高效 屏障barrier 协调多个线程并行工作每个线程等待直到全部线程都到达一点然后从该点进行执行 适用于固定数量的线程的并行算法、数据初始化等 简化同步的代码 某些实现中当线程在屏障上等待时会消耗 CPU 资源忙等只适用于固定数量的线程 和Memory Barrier是两种使用pthread_barrier_* 接口。类似Java中的CyclicBarrier或者C20的std::barrier
由于linux下线程和进程本质都是LWP那么进程间通信使用的IPC管道、FIFO、消息队列、信号量线程间也可以使用也可以达到相同的作用。但是由于IPC资源在进程退出时不会清理因为它是系统资源因此不建议使用。
以下是一些非锁但是也能实现线程安全或者部分线程安全的常见做法 名称 简介 常见应用场景 优势 劣势 备注 原子赋值 简单类型的对齐读取和写入通常是原子的。比如 int32、int64 双buffer切换时候的指针赋值共享内存中简单类型之间修改 无锁且实现简单 本质上单条处理器指令是原子的并非所有处理器架构都保证是原子的 简单原子变量(atomic) 通过编译器、语言等实现原子的CPU指令 原生类型的自增自减和立即数赋值等、不强调先后只追求最终结果的正确 性能高、实现简单 gcc、C、C、Java等都有实现所有原子类型都不支持拷贝没有浮点类型的原子变量 CAS(简单原子变量就是一种weak的CAS) 内存屏障 对执行的先后顺序有严格要求 性能高、实现简单 在竞争严重的时候自旋可能非常浪费CPU 双buffer 在内存中保存两份 更新不频繁的数据 性能高、无需加锁 浪费空间 只适用于一写多读的场景 延迟删除双bufferDouble Buffering with Deferred Deletion 在更新后短期内双buffer而后删除旧版本通过指针赋值的原子性切换到新数据 更新不频繁的数据读多写少的场景 性能高、无需加锁 更新频率有限制 只适用于一写多读的场景 thread_local 每个线程持有一份数据彻底摆脱线程同步 线程间无需实时交互 性能高、无需加锁 每个线程都有一个实例 per-cpu变量 每个处理器都分配了该变量的副本 绑核后无锁读写 性能高、无需加锁 参考DEFINE_PER_CPUget_cpu_var等 RCURead-Copy-Update 需要修改时候创建副本然后切换副本 读多写少的场景 参见rcu_read_lockhttps://liburcu.org/
可以看到上面很多做法都是采用了副本尽量避免在 thread 中间共享数据。最快的同步就是没同步(The fastest synchronization of all is the kind that never takes place)share nothing is best。