周末,聊点轻松的话题。
本文通过一个不甚成功的优化自旋锁的半吊子尝试,聊一下操作系统并行的难点,中间的部分,说说图灵机和冯诺伊曼体系。
和往常一样,我的文章对概念的阐述非常少,更多的是一些需要思考的东西,一些没有正确答案的东西,所以还希望:
- 希望读者不要按照既有的观念赞同或者反驳,我们都希望看到的是更多的可能性。
- 此外,代码可能不严谨,我个人渣渣编程能力有限,所以我可能只是表达想法,如果有编程高手能帮忙完善,在下感激不尽!
- 最后,当然是个免责声明。
Linux内核的自旋锁饱受诟病。说它饱受诟病并非意味着它臭名昭著,相反,自旋锁在SMP并行 短同步区 场景下被认为是具有最佳性价比的方案。类似,公交车站不设候车厅,因为大部分公交车都是马上就到了的,候车厅反而更加消耗资金和候车者坐下再站起的卡路里消耗。
自旋锁更多被人怒其不争,因此出现了很多优化它的方案:
这是一个良性的过程。今天我自己再尝试一下。直接开始吧。
假设多个线程要访问共享变量,最最常规的写法如下:
// t1.c
#include
#include
#include
#include
#include
#include
// 共享数据
static int curr = 0;
static pthread_spinlock_t spin;
int tcnt;
int stop = 0;
// 每个线程自己的非共享任务,这个用于比较CPU的有效率。
// 重要提示:我假设self私有任务和访问共享数据的任务无相互依赖!所以它们之间没有必要同步!
int self[128] = {0};
void print_result()
{
stop = 1;
}
void do_task()
{
int i = 0, j = 2, k = 0;
pthread_spin_lock(&spin);
curr ++;
for (i = 0; i < 0xfff; i++) {
k += i/j;
}
pthread_spin_unlock(&spin);
}
void* func(void *arg)
{
int id = *(int *)arg;
while (!stop) {
do_task(); // 串行同步访问共享数据。
self[id] ++; // 安全地访问线程自己的数据。
}
// 当线程结束时,看看自己的私有任务和共同的任务都做了多少。
printf("Thread[%d] %d %d\n", id, self[id], curr);
}
int main(int argc, char **argv)
{
int err, i;
pthread_t tid;
struct itimerval tick = {0};
tcnt = atoi(argv[1]);
signal(SIGALRM, print_result);
tick.it_value.tv_sec = atoi(argv[2]);
tick.it_value.tv_usec = 0;
setitimer(ITIMER_REAL, &tick, NULL);
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
for (i = 0; i < tcnt; i++) {
self[i] = i;
err = pthread_create(&tid, NULL, func, &self[i]);
if (err != 0) {
exit(1);
}
}
while(1) {
sleep(10);
}
return 0;
}
OK,貌似没有任何问题。
但是,我们知道无论是使用自旋锁还是信号量,都是有缺陷的:
换句话说,在现代计算机体系结构中,所有的任务(线程或者进程)都是一条顺序的指令执行流,这意味着一旦遇到spinlock或者信号量,这个执行流就会停滞不前,要么原地空转,要么切换到别的任务。
CPU空转和进程切换,你会选择哪个?这看起来是个必须二选一的问题,但是,我们仔细思考一下这个问题的根因:
那么为何不让一个线程单独去做这个呢?好嘞,这就是方案!
问题是如何实现 让单独的线程去做串行化 操作呢?
注意上述代码注释里的那条假设: “我假设self私有任务和访问共享数据的任务无相互依赖!所以它们之间没有必要同步!”
但由于一个线程或者进程的指令执行流中由于访问共享数据遭遇了spinlock或者信号量,这些本可以独立异步执行的私有任务也不得不阻塞在那里。这问题非常类似于通信网络中的 队头拥塞 。
所以在我们的新方案中,宗旨有两个:
现在需要实现一个锁, 既不能自旋空转,又不能切换。
在《被神话的Linux, 一文带你看清Linux在多核可扩展性设计上的不足》一文中,我创建了独立的服务线程来模拟微内核的服务进程,无奈微内核的概念最近由于华为比较敏感,又由于Linux内核的模型先入为主,微内核从来不被认可,我也因此被喷,被人认为是华为的五毛…所以本文我不再提微内核的概念,不再模拟微内核的服务进程,所以我也就不创建服务线程来单独处理串行操作。
我创建的线程全部都是工作线程,和上面那个传统的并行争锁版本不同的仅仅是 不再自旋。 是的,这是一个 协作式的锁 :
核心是引入了一个backlog队列。
我们直接看代码吧:
// t2.c
#include
#include
#include
#include
#include
#include
// 共享数据
static int curr = 0;
static pthread_spinlock_t spin;
static pthread_spinlock_t spin_man;
int queue = 0;
int tcnt;
int stop = 0;
int self[128] = {0};
void print_result()
{
stop = 1;
}
void task()
{
int i = 0, j = 2, k = 0;
curr ++;
for (i = 0; i < 0xfff; i++) {
k += i/j;
}
}
// 所有的lock操作,全部是trylock,无自旋。
void do_task()
{
if (!pthread_spin_trylock(&spin)) { // 如果拿到锁,那就做事。
task(); // 做同步访问。
while (!pthread_spin_trylock(&spin_man)) { // 谁拿到锁,在解锁前谁处理backlog队列里的pending任务。
if (queue > 0) {
queue --;
pthread_spin_unlock(&spin_man);
task();
} else {
pthread_spin_unlock(&spin_man);
break;
}
}
pthread_spin_unlock(&spin);
} else { // 如果没有拿到锁,那就把任务放进backlog队列
pthread_spin_lock(&spin_man);
queue ++;
pthread_spin_unlock(&spin_man);
}
}
void* func(void *arg)
{
int id = *(int *)arg;
while (!stop) {
do_task();
self[id] += 1;;
}
printf("Thread[%d] %d %d\n", id, self[id], curr);
}
int main(int argc, char **argv)
{
int err, i;
pthread_t tid;
struct itimerval tick = {0};
tcnt = atoi(argv[1]);
signal(SIGALRM, print_result);
tick.it_value.tv_sec = atoi(argv[2]);
tick.it_value.tv_usec = 0;
setitimer(ITIMER_REAL, &tick, NULL);
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
pthread_spin_init(&spin_man, PTHREAD_PROCESS_PRIVATE);
for (i = 0; i < tcnt; i++) {
self[i] = i;
err = pthread_create(&tid, NULL, func, &self[i]);
if (err != 0) {
exit(1);
}
}
while (1) {
sleep(10);
}
return 0;
}
这段代码毛病很多,但先理解这种新的方式。哦,不,这并不新,Linux内核在TCP收包时便采用了这种类似的锁:
Linux内核的TCP收包逻辑之所以这么做,目的是 希望数据的复制在统一的进程上下文中进行,减少cache miss。
也许,还有用户态的什么库采用了这种,我不是开发者所以我不知道。对了,据我所知的,这个思想在现实中有一个实现,即 Critical Section Integration (CSI) ,详情参见:
https://users.ece.cmu.edu/~omutlu/pub/acs_asplos09.pdf
只不过我这个实现完全 异步化 了。之所以如此,在我看来,自旋空转是一种对能源的浪费,是一种罪恶!何不利用本来浪费在空转上的能源来做点有用的事呢?
如何能不自旋空转的前提下又能不切换,这就是这种 协作锁 的初始思路。
好了,我们看看比较的效果,在8核处理器的测试机上我们分别来10个线程,先看看并行争锁方案的成绩:
[root@10 newq]# ./t1 10 0
Thread[3] 25064 220830
Thread[7] 24789 220834
Thread[6] 16893 220835
Thread[9] 25280 220831
Thread[0] 20899 220836
Thread[4] 25179 220837
Thread[5] 19051 220838
Thread[1] 23248 220832
Thread[2] 20843 220833
Thread[8] 19637 220829
再看带有backlog队列的版本的成绩:
[root@10 newq]# ./t2 10 0
Thread[1] 8636523 218436
Thread[3] 9773773 218436
Thread[0] 8066399 219443
Thread[4] 9379152 218436
Thread[8] 7816979 219438
Thread[9] 9402372 219436
Thread[7] 7607238 219454
Thread[6] 10396157 225456
Thread[2] 8686164 227068
Thread[5] 8191685 245526
第三列是共享数据的操作成绩,几乎是一致的,backlog版本的锁开销稍大,但基本可以忽略,现在看看第二列,明显backlog版本的自己私有事情做的更多, 这显然是不自旋的收益!
以上并不是我想表达的全部。我想表达的核心是:
近日在看Rust, “锁数据而不是锁代码” 是一个很妙的思想。那么进一步,如果把一个任务也看作是数据呢?嗯,一切都是数据!
如同下面的代码操作一个共享数据:
shared_data ++;
如果一个任务也看作数据,那么下面的表述就是理所当然的:
void operate_shared_data(void *arg)
{
...
}
do_some_thing(operate_shared_data, arg);
按照这个思路,把上述的t2.c改成下面的形式:
// t3.c
#include
#include
#include
#include
#include
#include
// 共享数据
static int curr = 0;
static pthread_spinlock_t spin;
static pthread_spinlock_t spin_man;
int queue = 0;
int tcnt;
int stop = 0;
int self[128] = {0};
void print_result()
{
stop = 1;
}
void task(void *arg)
{
int *shparam = (int *)arg;
int i = 0, j = 2, k = 0;
*shparam ++;
for (i = 0; i < 0xfff; i++) {
k += i/j;
}
}
// 同步任务的执行封装:
// 1. 能抢到锁就做;
// 2. 不能抢到锁就甩锅。
void do_locked_task(void (*task_callback)(void *), void *arg)
{
if (!pthread_spin_trylock(&spin)) {
task_callback(arg);
while (!pthread_spin_trylock(&spin_man)) {
if (queue > 0) {
queue --;
pthread_spin_unlock(&spin_man);
task_callback(arg);
} else {
pthread_spin_unlock(&spin_man);
break;
}
}
pthread_spin_unlock(&spin);
} else {
pthread_spin_lock(&spin_man);
queue ++;
pthread_spin_unlock(&spin_man);
}
}
void* func(void *arg)
{
int id = *(int *)arg;
while (!stop) {
do_locked_task(task, &curr); // 把需要锁定的任务进行封装!
self[id] += 1;
}
printf("Thread[%d] %d %d\n", id, self[id], curr);
}
int main(int argc, char **argv)
{
int err, i;
pthread_t tid;
struct itimerval tick = {0};
tcnt = atoi(argv[1]);
signal(SIGALRM, print_result);
tick.it_value.tv_sec = atoi(argv[2]);
tick.it_value.tv_usec = 0;
setitimer(ITIMER_REAL, &tick, NULL);
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
pthread_spin_init(&spin_man, PTHREAD_PROCESS_PRIVATE);
for (i = 0; i < tcnt; i++) {
self[i] = i;
err = pthread_create(&tid, NULL, func, &self[i]);
if (err != 0) {
exit(1);
}
}
while (1) {
sleep(10);
}
return 0;
}
虽然实际运行效果并没有变,但是操作共享数据的思路完全不同。我们不再需要考虑哪些代码需要被锁包住以及如何包住了。
很多人可能又要说了,如果这些争锁事件发生在进程陷入内核的不同上下文中,会不会增加切换的开销呢?
确实,代码如此一来的颠三倒四,额外开销确实会有,如何评估是否比自旋锁好,值得评估。
但是…
切换开销又如何?共享数据难道不是所有线程都能平等操作的吗?此外,别动不动就切换开销,很多现代的研究表明,进程抽象可能本身就不对,粒度太粗了,正常大小的任务粒度应该是 函数。
还是那句话,UNIX/Linux的模型早就先入为主了,固化了人们的观念。这次说的不是其宏内核模型,而是更底层的进程模型。
…
预警,以下的内容需要思辨。
这就要说说图灵机了。
图灵是伟大的,所以图灵机是伟大的,是这样吗?我们先要看看图灵机是什么。
图灵机是一台抽象的机器,它可以完成任何在有限步骤下可以完成的工作。
任何可计算的问题图灵机都能计算。 图灵机最大的影响力在于,它定下了一个调子,让后来的程序员模仿图灵机的计算过程来编程。
冯诺伊曼定义了一个符合图灵机约束的( 图灵完备的 )具体模型,即 存储执行模型。 从图灵机到冯诺伊曼模型, “解决问题的步骤” 是核心,最终UNIX将这个步骤抽象成了 进程 的概念。
历史是这样发展的:
此后的多道程序设计,时间片调度,线程,协程,X程,Y程,Z程,说白了就是 如何组织多个任务的多个图灵计算步骤 的问题。
我们现在编写一个程序,其实就是在编写一个解决问题的一系列步骤,或者说在定义一部图灵机,然后将其放在一个冯诺伊曼式的机器中去具体执行,只不过这台机器是虚拟的机器,该虚拟机器就是 进程。
后面超级多的问题都围绕着进程这个抽象的概念展开,什么切换开销啦,什么内存隔离啦,什么cache刷新啦…似乎计算机里必须跑一个或者几个进程一样。
以Intel处理器为例,似乎机器里提供的标识进程页目录的CR3寄存器是与生俱来的一样,事实却是,Intel处理器只是想分时执行多部图灵机的具象,而已。
其实,早在图灵,冯诺伊曼他们之前很久,人们造出来的各种 计算机器 均是图灵机,比如中国的算盘。只不过古代理论体系尚未完善。事实上,人们解决任何问题都会试图将方法分解成一系列顺序的可执行的步骤,把这个步骤序列做完,问题就随之解决。
后来,这个步骤序列被称作了作业,现在,它叫进程。
现在我们找到了罪魁祸首,本文开头的自旋锁的优化,自旋空转耽误后面的代码执行是由于指令流是顺序的,而切换导致开销是由于进程抽象的分时执行。
但是用进程解决问题,这是我们习惯的方式吗?
这也许只是图灵当时或冲动或有计划地想出来的一种 机器可能的解决问题的方式 ,但这肯定不是人习惯的解决问题的方式。比如说,计算机和我们分别如何计算3乘以4?
熟悉简单数字电路的都应该知道乘法器的实现原理,它在一个特定的时序中通过一系列门电路的 顺序操作 实现一个乘法运算。对,我们的C代码里的乘法,比如:
int r = 3*4;
落实到CPU上就是这么算乘法的。如果下次再有一个乘法运算,乘法器还需要从头开始把相同的步骤重来一遍。
推荐一篇我之前写过的一篇文章:
原始人的除法引发的闲聊: https://blog.csdn.net/dog250/article/details/16905147
如果仔细观察现代计算机实施计算的步骤,就会发现,其实计算机采用的正是野人的方式,
但是人不会这么算。人会记下基本的乘法口诀,重用已有的结论。
当计算机在计算123乘以321的时候,乘法器或按照和计算3乘以4的完全一致的步骤去计算,但是人会列出竖式,每一步重用乘法口诀。计算机比人快,只是因为门电路比脑回路快,如果把我们的大脑用门电路实现,结局却不一定。
让我们再举例。解下面的方程:
x 2 + 2 = 0 x^2+2=0 x2+2=0
首先,现代计算机并不认识这个方程式子,现代计算机都是图灵机,所以计算机去解这个方程,必须 将解方程这件事分解成一系列的顺序的步骤。 ,所以,如果编程的话,必须穷举,类似下面的代码:
#include
#include
int main()
{
int x;
for (x = 0; x < 0xffffffff; x++) {
int y;
y = x*x + 2;
if (y ==0) {
printf("Got it: %d\n", x);
}
}
}
也许有更加 优化 的代码,但是计算机穷举的本质不会改变。从随便一段程序,到处理器里逻辑门实现的电路,都是按照图灵机的模型设计的。
现在让人来解这个方程。人可能直接就能通过 逻辑 看出这个方程无解,因为人会重用一个结论,即平方是非负数。
现在来解一个有解的方程:
x 2 − 3 x + 2 = 0 x^2-3x+2=0 x2−3x+2=0
计算机代码如下:
#include
#include
int main()
{
int x;
for (x = 0; x < 0xffffffff; x++) {
int y;
y = x*x - 3*x*x + 2;
if (y ==0) {
printf("Got it: %d\n", x);
}
}
}
天啊。
现在让一个中学生去算,他可能会因式分解:
( x − 1 ) ( x − 2 ) = 0 (x-1)(x-2)=0 (x−1)(x−2)=0
或者他会利用判别式然后一元二次方程的求根公式:
x = − b ± b 2 − 4 a c 2 2 a x=\dfrac{-b\pm\sqrt[2]{b^2-4ac}}{2a} x=2a−b±2b2−4ac
如此简单的数学关系,计算机却不可能学会。于是,如果问该中学生如何编程解这个方程,他或许会说:
#include
#include
#include
int main()
{
int a = 1, b = -3, c = 2;
double x1, x2;
if (b*b < 4*a*c) {
printf("no solution\n");
return -1;
}
x1 = (b*b + sqrt(b*b - 4*a*c))/(2*a)
x2 = (b*b + sqrt(b*b - 4*a*c))/(2*a)
printf("Got it: %d %d\n", x1, x2);
}
代码对吗?没问题,但这是你强迫计算机做的,不是它自愿的,哈哈哈。
现在有结论了,图灵机不会归纳和总结且无法重用之前的结论。按照人的方式,即便此人不懂数学,但起码他会在大脑中创建一张表,每次把一些常用结论存在这张表里,解决问题的时候会先去查表,但计算机不会,计算机只会重新开始。
计算机也许可以记忆一些结论,然而进一步地,它并不能判断什么该记忆,什么不该记忆。
按照图灵机的模式设计的程序关注的是解决问题的步骤,而不是数学关系。 按照这种解决问题的步骤组织起来的程序,就是 命令式编程 。
反之,按照数学关系组织起来的程序,叫做 函数式编程 。遗憾的是,由于当前计算机硬件都是 命令式编程设计 的,即便是函数式编程的程序也只能寄人篱下了。
如今当单个处理器的性能贴近极限而很难继续优化时,多处理器就成了一个提升性能的显而易见的替代方案。
然而,并行优化却成了世界难题。关于并行操作系统的性能优化,似乎有绕不开的坎。
是不是UNIX进程模型错了,进一步,是不是顺序执行指令的冯诺伊曼体系错了,再进一步,是不是图灵机本身就不对。
现代操作系统的线程(最开始是进程)是最小的计算单元,而线程是一个顺序的指令流,多个线程,多个线程或者进程独立地在多个处理器上并行执行,数据同步问题难以避免,在现代操作系统的框框里,无论如何都没法避免这种数据同步。
多个线程共享一个变量A,多个线程共同非原子地操作A,必然需要同步,问题就出在 A是可变的。
A = A + 1;
在冯诺伊曼体系的机器里化作一个操作步骤就是:
load A
add A, 1
store A
A永远是A,它的值随着时间而改变,但何时改变A以及谁改变A却不能确定。单独的A在时间和空间两个维度疯狂变换着。
问题在于, 同时只能有一个线程改变A!
这不是并行处理的正确的方式。
并行处理应该是操作独立的计算单元,且互相不影响。
并行其实很简单:
很显然,多个计算单元可以同时运行。因为它们不会写同一个变量。
我们人的大脑处理问题的过程就是一个很好的例子。
大脑中不存在可变的A,如果A加上1,那么就不是A了,而是变成了另一个变量,A还是原来的A。
我们不可能因为一个人变了而忘记他以前的样子(然而计算机执行A=A+1之后就会忘记A以前的值了) , 相反,我们可以同时回忆起一个人在不同阶段所有的样子。
这也是数学上的处理方式,比如在数学上,等号表示一种状态而不是一个赋值动作,以下的式子:
A = A + 1 A=A+1 A=A+1
在数学上是错误的。
一个变量的改变需要时间,消除了变量的改变便可以消除时间,消除了时间就可以做到完全的并行。换句话说,并行的是一系列的关系,而不是一系列的动作。
这么看来,貌似函数式编程可以解决一切问题。然而遗憾的是,没有支撑函数式编程的硬件。 这让人担心命令式的冯诺伊曼机器是否会阻碍人工智能(也就是大红大紫的AI)的发展。
说到函数式编程,就要讨论下什么是函数。
在数学上,函数表示一种关系,给定一个自变量 x x x,对它进行一种关系变换 f f f,得到另一个值 y y y,写作:
y = f ( x ) y=f(x) y=f(x)
然而,现代计算机编程歪曲了函数的概念,编程领域的函数不仅仅包括自变量和值的关系,还包括操作逻辑,也就是说,编程领域的函数是 一段顺序的指令序列 ,如果没有多处理器并行,这似乎不是什么问题,数学上:
y = f ( x ) y=f(x) y=f(x)
编程上,一个C函数表示如下:
void * f(void *x)
{
void *y;
y = some_opt(x);
return y;
}
谁在乎some_opt是什么。
在数学上,我们知道 f f f是确定的,然而在多处理器上,some_opt却是未知的(谁保证哪个处理器不会改变some_opt操作的状态),这令人遗憾。实际上,上述C函数其实只是一个调用例程而已,它并不符合数学上函数的定义。
函数式编程貌似回归了数学函数的本质,这让人看到了希望。完美并行的前提,其实就是消除依赖。
函数而不是线程/进程,才是计算的基本单元,多处理器上调度并行的已经消除共享变量的函数,这才是正确的并行模型。
C语言的全局变量是罪魁祸首(图灵机依赖一个纸带,冯诺伊曼定义了内存),开了一个让人恶心的先河。Java似乎意识到了这一点,取消了全局变量,但是呵呵…
Java的public修饰符作用到了类变量,你只要拥有一个类的实例,就能引用其public变量,如果是一个static变量,你只需要引用类名即可,这和全局变量有何区别,我看不出来。并发还是要同步!
总之,在现有的计算模型的约束下,很难有什么突破性的并发方案,顶多只是优化。推翻现有的体系(根源在图灵机)又不可能,怎么办?值得思考。
如果我们上一个函数式编程语言来写程序,事实上在最底层,落实到CPU指令的时候,还是需要 一系列的步骤 ,还是无法并行表达一系列的关系,因为当今的CPU执行的就是一系列的步骤。
我们在这种CPU上试图进行更加符合数学式思考的函数式编程,试图更进一步地模拟人的大脑,注定是徒劳的,这令人遗憾。你能想象一种新的计算硬件是什么样子吗?量子的?意念的?我是想象不到,但希望一直都在。
…
不晓得看一遍温州皮鞋厂老板推荐的《程序设计语言原理》有没有帮助,试试看吧。
还有很多值得思考的东西,欢迎一起讨论。
浙江温州皮鞋湿,下雨进水不会胖。