Linux 内核101:[译]并发导论

原文:Operating Systems: Three Easy Pieces:Concurrency: An Introductionbash

进程和线程

进程和线程在底层的区别

在单线程进程中,只有一个execution flow,进程只能从一个 PC(Program counter)里面获取指令。多线程的进程有多个 execution flow,可以从多个 PCs 获取指令。要简单的对比一下进程和线程的话,就是每一个 thread 很像一个独立的进程,可是同一个进程里面的线程共享一部分数据,同时共享地址空间微信

操做系统如何调度线程

每一个线程有本身独立的PC和寄存器。也就是说,运行在同一个核的的两个线程 T一、T2,当CPU 从 T1切换到 T2执行的时候,会像进程切换同样,发生一次context switch。 CPU 须要把 T1 的运行状态和寄存器的数据保存起来,而后 restore T2的状态和寄存器数据。对于进程,状态被保存在 PCB(process control block);对于线程,使用的是 TCBs(Thread control block)。多线程

线程和进程切换还有一点不一样是:若是操做系统调度切换的两个线程是属于同一个进程的,那么地址空间就不须要切换,由于线程间是共享同一个地址空间的。这也就意味着线程切换相对于进程切换更加轻量级。oop

进程和线程能实现并行

首先,一个核在同一时刻只能执行一个进程(或者线程,下同)。以下图左所示。学习

要在同一时刻运行多个进程,必需要有多个核。由于操做系统有一套调度系统,因此能把多个进程分配给多个核。ui

线程调度全看操做系统喜欢

咱们假设下面这个例子中:只有一个核。spa

下面这个程序主线程先用Pthread_create建立两个线程,这两个线程的做用就是简单的打印A或者B,而后主线程调用Pthread_join等待两个线程结束,最后主线程退出。操作系统

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#include "common.h"
#include "common_threads.h"

void *mythread(void *arg) {
    printf("%s\n", (char *) arg);
    return NULL;
}

int main(int argc, char *argv[]) {                    
    if (argc != 1) {
	fprintf(stderr, "usage: main\n");
	exit(1);
    }

    pthread_t p1, p2;
    printf("main: begin\n");
    Pthread_create(&p1, NULL, mythread, "A"); 
    Pthread_create(&p2, NULL, mythread, "B");
    // join waits for the threads to finish
    Pthread_join(p1, NULL); 
    Pthread_join(p2, NULL); 
    printf("main: end\n");
    return 0;
}
复制代码

有两点:线程

  1. 一个线程先被建立,但它不必定会先被执行。
  2. 一个线程被建立,但它不必定会当即被执行。

可能会出现下面三种状况:3d

第一种:A 在 B 以前被执行。

第二种:线程被建立以后当即被执行,Pthread_join将会当即返回。

第三种: B 在 A 以前被执行。

从这个例子咱们能够看到,线程的建立和调度是由操做系统来调度地,你没法判断哪一个线程会先被执行,何时被执行。

线程共享变量带来的问题

下面这个程序建立两个线程,每一个线程将共享的全局变量counter作N次加一,因此咱们预期最终的结果将会是2N。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#include "common.h"
#include "common_threads.h"

int max;
volatile int counter = 0; // shared global variable

void *mythread(void *arg) {
    char *letter = arg;
    int i; // stack (private per thread) 
    printf("%s: begin [addr of i: %p]\n", letter, &i);
    for (i = 0; i < max; i++) {
	counter = counter + 1; // shared: only one
    }
    printf("%s: done\n", letter);
    return NULL;
}
                                                                             
int main(int argc, char *argv[]) {                    
    if (argc != 2) {
	fprintf(stderr, "usage: main-first <loopcount>\n");
	exit(1);
    }
    max = atoi(argv[1]);

    pthread_t p1, p2;
    printf("main: begin [counter = %d] [%x]\n", counter, 
	   (unsigned int) &counter);
    Pthread_create(&p1, NULL, mythread, "A"); 
    Pthread_create(&p2, NULL, mythread, "B");
    // join waits for the threads to finish
    Pthread_join(p1, NULL); 
    Pthread_join(p2, NULL); 
    printf("main: done\n [counter: %d]\n [should: %d]\n", 
	   counter, max*2);
    return 0;
}
复制代码

有的时候,结果和咱们预期的一致:

有时候又不一致:

N越大偏离地越离谱。

上述问题的根源:不可控的调度

counter加1的操做,生成的汇编代码以下:

mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
复制代码
  • 假设counter变量在内存地址0x8049a1c处。
  • mov 0x8049a1c, %eax把内存0x8049a1c的值加载到寄存器%eax
  • add $0x1, %eax将寄存器%eax地值加一。
  • mov %eax, 0x8049a1c把寄存器%eax地值写入0x8049a1c

想象一下两个线程一块儿运行上面这段代码时会发生什么不可预期的状况:

假如如今counter的值为50,T1执行了前面两行,那么它寄存器的值将会是51。若是这时候 interrupt 发生,操做系统会把T1地当前状态保存到它的 TCB,固然这也就包括了它的寄存器%eax的值。因此,当前的状况是:T1寄存器的值为51,可是内存0x8049a1c处的值仍是50,由于 T1还没来得及把值写到内存里面去。

这个时候一个 context switch 就会发生,操做系统有两种选择:运行 T1或者运行 T2。若是是继续运行 T1,一切都是正常的,T1会接着执行第三行代码,把值51写入内存相应位置。这里咱们假设操做系统会运行 T2,那问题就来了。T1执行第一行的时候,内存中的值仍是51,若是 T2成功执行了完整的三行代码,就会把值51写入内存。

又一次 context switch 发生,此次假设是 T1运行。T1接着运行第三行代码,把本身独立寄存器的值(这里是51)写入内存,内存的值将仍是51。

发现了吗?两个线程作了两次相加操做,可是counter的值只增长了1。

假如上诉汇编代码在内存中的地址以下(第一条在地址100处):

100 mov 0x8049a1c, %eax
105 add $0x1, %eax
108 mov %eax, 0x8049a1c
复制代码

下面这个图展现了上述发生的过程:执行两次相加,可是结果只增长了1。

对原子化操做的渴望

解决上诉问题的思路很简单,那就是原子化执行。若是加一的操做能用一条指令完成,那就不存在interrupt 带来的问题了:若是这条指令没有"中间状态",事情就可以往咱们预期的方向发展。

memory-add 0x8049a1c, $0x1
复制代码

可是现实是,没有这么多强大的原子化指令。因此就须要硬件提供一些指令,让咱们实现同步的功能,这些是咱们后面将要学习的内容。

若是你像我同样真正热爱计算机科学,喜欢研究底层逻辑,欢迎关注个人微信公众号:

相关文章
相关标签/搜索