操作系统导论:并发

并发:介绍

线程(thread)
经典观点是一个程序只有一个执行点(一个程序计数器,用来存放要执行的指令),但多线程(multi-threaded)程序会有多个执行点(多个程序计数器,每个都用于取指令和执行)。换一个角度来看,每个线程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据。

线程有一个程序计数器(PC),记录程序从哪里获取指令。每个线程有自己的一组用于计算的寄存器。所以,如果有两个线程运行在一个处理器上,从运行一个线程(T1)切换到另一个线程(T2)时,必定发生上下文切换(context switch)。线程之间的上下文切换类似于进程间的上下文切换。对于进程,我们将状态保存到进程控制块(Process Control Block,PCB)。现在,我们需要一个或多个线程控制块(Thread Control Block,TCB),保存每个线程的状态。但是,与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变(即不需要切换当前使用的页表)。

线程进程之间的另一个主要区别在于栈。在简单的传统进程地址空间模型 [我们现在可以称之为单线程(single-threaded)进程] 中,只有一个栈,通常位于地址空间的底部

在这里插入图片描述
在多线程的进程中,每个线程独立运行,当然可以调用各种例程来完成正在执行的任何工作。不是地址空间中只有一个栈,而是每个线程都有一个栈。

1.1 实例:线程创建

主程序创建了两个线程,分别执行函数mythread(),但是传入不同的参数(字符串类型的A或者B)。一旦线程创建,可能会立即运行(取决于调度程序的兴致),或者处于就绪状态,等待执行。创建了两个线程(T1和T2)后,主程序调用pthread_join(),等待特定线程完成。

1    #include <stdio.h>
2    #include <assert.h>
3    #include <pthread.h>
4
5    void *mythread(void *arg) {
6        printf("%s\n", (char *) arg);
7        return NULL;
8    }
9
10   int
11   main(int argc, char *argv[]) {
12       pthread_t p1, p2;
13       int rc;
14       printf("main: begin\n");
15       rc = pthread_create(&p1, NULL, mythread, "A"); assert(rc == 0);
16       rc = pthread_create(&p2, NULL, mythread, "B"); assert(rc == 0);
17       // join waits for the threads to finish
18       rc = pthread_join(p1, NULL); assert(rc == 0);
19       rc = pthread_join(p2, NULL); assert(rc == 0);
20       printf("main: end\n");
21       return 0;
22   }

这个小程序的可能执行顺序
在这里插入图片描述
但请注意,这种排序不是唯一可能的顺序。实际上,给定一系列指令,有很多可能的顺序,这取决于调度程序决定在给定时刻运行哪个线程。例如,创建一个线程后,它可能会立即运行,这将导致表26.2中的执行顺序。
在这里插入图片描述
我们甚至可以在“A”之前看到“B”,即使先前创建了线程1,如果调度程序决定先运行线程2,没有理由认为先创建的线程先运行。
在这里插入图片描述
线程创建有点像进行函数调用。然而,并不是首先执行函数然后返回给调用者,而是为被调用的例程创建一个新的执行线程,它可以独立于调用者运行,可能在从创建者返回之前运行,但也许会晚得多。

1.2 为什么更糟糕:共享数据

1    #include <stdio.h>
2    #include <pthread.h>
3    #include "mythreads.h"
4
5    static volatile int counter = 0;
6
7    //
8    // mythread()
9    //
10   // Simply adds 1 to counter repeatedly, in a loop
11   // No, this is not how you would add 10,000,000 to
12   // a counter, but it shows the problem nicely.
13   //
14   void *
15   mythread(void *arg)
16   {
17       printf("%s: begin\n", (char *) arg);
18       int i;
19       for (i = 0; i < 1e7; i++) {
20           counter = counter + 1;
21       }
22       printf("%s: done\n", (char *) arg);
23       return NULL;
24   }
25
26   //
27   // main()
28   //
29   // Just launches two threads (pthread_create)
30   // and then waits for them (pthread_join)
31   //
32   int
33   main(int argc, char *argv[])
34   {
35       pthread_t p1, p2;
36       printf("main: begin (counter = %d)\n", counter);
37       Pthread_create(&p1, NULL, mythread, "A");
38       Pthread_create(&p2, NULL, mythread, "B");
39
40       // join waits for the threads to finish
41       Pthread_join(p1, NULL);
42       Pthread_join(p2, NULL);
43       printf("main: done with both (counter = %d)\n", counter);
44       return 0;
45   }

以下是关于代码的一些说明。首先,如Stevens建议的[SR05],我们封装了线程创建和合并例程,以便在失败时退出。对于这样简单的程序,我们希望至少注意到发生了错误(如果发生了错误),但不做任何非常聪明的处理(只是退出)。因此,Pthread_create()只需调用pthread_create(),并确保返回码为0。如果不是,Pthread_create()就打印一条消息并退出。

其次,我们没有用两个独立的函数作为工作线程,只使用了一段代码,并向线程传入一个参数(在本例中是一个字符串),这样就可以让每个线程在打印它的消息之前,打印不同的字母。

最后,最重要的是,我们现在可以看看每个工作线程正在尝试做什么:向共享变量计数器添加一个数字,并在循环中执行1000万(107)次。因此,预期的最终结果是:20000000。
在这里插入图片描述
在这里插入图片描述
每次运行不但会产生错误,而且得到不同的结果!

1.3 核心问题:不可控的调度

编译器为更新计数器生成的代码序列。编译器为更新计数器生成的代码序列。

mov 0x8049a1c, %eax 
add $0x1, %eax
mov %eax, 0x8049a1c

这个例子假定,变量counter位于地址0x8049a1c。在这3条指令中,先用x86的mov指令,从内存地址处取出值,放入eax。然后,给eax寄存器的值加1(0x1)。最后,eax的值被存回内存中相同的地址。

设想我们的两个线程之一(线程1)进入这个代码区域,并且因此将要增加一个计数器。它将counter的值(假设它这时是50)加载到它的寄存器eax中。因此,线程1的eax = 50。然后它向寄存器加1,因此eax = 51。现在,一件不幸的事情发生了:时钟中断发生。因此,操作系统将当前正在运行的线程(它的程序计数器、寄存器,包括eax等)的状态保存到线程的TCB。

现在更糟的事发生了:线程2被选中运行,并进入同一段代码。它也执行了第一条指令,获取计数器的值并将其放入其eax中 [请记住:运行时每个线程都有自己的专用寄存器。上下文切换代码将寄存器虚拟化(virtualized),保存并恢复它们的值]。此时counter的值仍为50,因此线程2的eax = 50。假设线程2执行接下来的两条指令,将eax递增1(因此eax = 51),然后将eax的内容保存到counter(地址0x8049a1c)中。因此,全局变量counter现在的值是51。

最后,又发生一次上下文切换,线程1恢复运行。还记得它已经执行过mov和add指令,现在准备执行最后一条mov指令。回忆一下,eax=51。因此,最后的mov指令执行,将值保存到内存,counter再次被设置为51。

简单来说,发生的情况是:增加counter的代码被执行两次,初始值为50,但是结果为51。这个程序的“正确”版本应该导致变量counter等于52。

由于执行这段代码的多个线程可能导致竞争状态,因此我们将此段代码称为临界区(critical section)。临界区是访问共享变量(或更一般地说,共享资源)的代码片段,一定不能由多个线程同时执行。

1.4 原子性愿望

解决这个问题的一种途径是拥有更强大的指令,单步就能完成要做的事,从而消除不合时宜的中断的可能性。

因此,我们要做的是要求硬件提供一些有用的指令,可以在这些指令上构建一个通用的集合,即所谓的同步原语(synchronization primitive)。通过使用这些硬件同步原语,加上操作系统的一些帮助,我们将能够构建多线程代码,以同步和受控的方式访问临界区,从而可靠地产生正确的结果—— 尽管有并发执行的挑战。

提示:使用原子操作
原子操作是构建计算机系统的最强大的基础技术之一,从计算机体系结构到并行代码(我们在这里研究的内容)、文件系统(我们将很快研究)、数据库管理系统,甚至分布式系统。

将一系列动作原子化(atomic)背后的想法可以简单用一个短语表达:“全部或没有”。看上去,要么你希望组合在一起的所有活动都发生了,要么它们都没有发生。不会看到中间状态。有时,将许多行为组合为单个原子动作称为事务(transaction),这是一个在数据库和事务处理世界中非常详细地发展的概念。

2 锁

2.1 锁的基本思想

举个例子,假设临界区像这样,典型的更新共享变量:

balance = balance + 1;
1    lock_t mutex; // some globally-allocated lock 'mutex'
2    ...
3    lock(&mutex);
4    balance = balance + 1;
5    unlock(&mutex);

锁就是一个变量,因此我们需要声明一个某种类型的锁变量(lock variable,如上面的mutex),才能使用。这个锁变量(简称锁)保存了锁在某一时刻的状态。它要么是可用的(available,或unlocked,或free),表示没有线程持有锁,要么是被占用的(acquired,或locked,或held),表示有一个线程持有锁,正处于临界区。我们也可以保存其他的信息,比如持有锁的线程,或请求获取锁的线程队列,但这些信息会隐藏起来,锁的使用者不会发现。

调用lock()尝试获取锁,如果没有其他线程持有锁(即它是可用的),该线程会获得锁,进入临界区

2.2 Pthread锁

1    pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
2
3    Pthread_mutex_lock(&lock);    // wrapper for pthread_mutex_lock()
4    balance = balance + 1;
5    Pthread_mutex_unlock(&lock);

POSIX的lock和unlock函数会传入一个变量,因为我们可能用不同的锁来保护不同的变量。这样可以增加并发:不同于任何临界区都使用同一个大锁(粗粒度的锁策略),通常大家会用不同的锁保护不同的数据和结构,从而允许更多的线程进入临界区(细粒度的方案)。

2.4 评价锁

在实现锁之前,我们应该首先明确目标,因此我们要问,如何评价一种锁实现的效果。为了评价锁是否能工作(并工作得好),我们应该先设立一些标准。第一是锁是否能完成它的基本任务,即提供互斥(mutual exclusion)。最基本的,锁是否有效,能够阻止多个线程进入临界区?

第二是公平性(fairness)。当锁可用时,是否每一个竞争线程有公平的机会抢到锁?用另一个方式来看这个问题是检查更极端的情况:是否有竞争锁的线程会饿死(starve),一直无法获得锁?

最后是性能(performance),具体来说,是使用锁之后增加的时间开销。有几种场景需要考虑。一种是没有竞争的情况,即只有一个线程抢锁、释放锁的开支如何?另外一种是一个CPU上多个线程竞争,性能如何?最后一种是多个CPU、多个线程竞争时的性能

2.5 控制中断

最早提供的互斥解决方案之一,就是在临界区关闭中断。

1    void lock() {
2        DisableInterrupts();
3    }
4    void unlock() {
5        EnableInterrupts();
6    }

假设我们运行在这样一个单处理器系统上。通过在进入临界区之前关闭中断(使用特殊的硬件指令),可以保证临界区的代码不会被中断,从而原子地执行。结束之后,我们重新打开中断(同样通过硬件指令),程序正常运行。
优点
没有中断,线程可以确信它的代码会继续执行下去,不会被其他线程干扰
缺点

  • 第一,一个贪婪的程序可能在它开始时就调用lock(),从而独占处理器。更糟的情况是,恶意程序调用lock()后,一直死循环。后一种情况,系统无法重新获得控制,只能重启系统。关闭中断对应用要求太多,不太适合作为通用的同步解决方案。
  • 第二,这种方案不支持多处理器。如果多个线程运行在不同的CPU上,每个线程都试图进入同一个临界区,关闭中断也没有作用。线程可以运行在其他处理器上,因此能够进入临界区。多处理器已经很普遍了,我们的通用解决方案需要更好一些。
  • 第三,关闭中断导致中断丢失,可能会导致严重的系统问题。假如磁盘设备完成了读取请求,但CPU错失了这一事实,那么,操作系统如何知道去唤醒等待读取的进程?
  • 最后一个不太重要的原因就是效率低。与正常指令执行相比,现代CPU对于关闭和打开中断的代码执行得较慢。

2.6 测试并设置指令(原子交换)

因为关闭中断的方法无法工作在多处理器上,所以系统设计者开始让硬件支持锁

最简单的硬件支持是测试并设置指令(test-and-set instruction),也叫作原子交换(atomic exchange)

1    typedef struct  lock_t { int flag; } lock_t;
2
3    void init(lock_t *mutex) {
4        // 0 -> lock is available, 1 -> held
5        mutex->flag = 0;
6    }
7
8    void lock(lock_t *mutex) {
9        while (mutex->flag == 1) // TEST the flag
10           ; // spin-wait (do nothing)
11       mutex->flag = 1;         // now SET it!
12   }
13
14   void unlock(lock_t *mutex) {
15       mutex->flag = 0;
16   }

当第一个线程正处于临界区时,如果另一个线程调用lock(),它会在while循环中自旋等待(spin-wait),直到第一个线程调用unlock()清空标志。然后等待的线程会退出while循环,设置标志,执行临界区代码。

遗憾的是,这段代码有两个问题:正确性和性能。

2.7 实现可用的自旋锁

1    int TestAndSet(int *old_ptr, int new) {
2        int old = *old_ptr; // fetch old value at old_ptr
3        *old_ptr = new;    // store 'new' into old_ptr
4        return old;        // return the old value
5    }

测试并设置指令做了下述事情。它返回old_ptr指向的旧值,同时更新为new的新值。当然,关键是这些代码是原子地(atomically)执行。因为既可以测试旧值,又可以设置新值,所以我们把这条指令叫作“测试并设置”。这一条指令完全可以实现一个简单的自旋锁(spin lock)

1    typedef struct  lock_t {
2        int flag;
3    } lock_t;
4
5    void init(lock_t *lock) {
6        // 0 indicates that lock is available, 1 that it is held
7        lock->flag = 0;
8    }
9 
10   void lock(lock_t *lock) {
11       while (TestAndSet(&lock->flag, 1) == 1)
12           ; // spin-wait (do nothing)
13   }
14 
15   void unlock(lock_t *lock) {
16       lock->flag = 0;
17   }

2.8 评价自旋锁

锁最重要的一点是正确性(correctness):能够互斥吗?答案是可以的:自旋锁一次只允许一个线程进入临界区。因此,这是正确的锁。

下一个标准是公平性(fairness)。自旋锁对于等待线程的公平性如何呢?能够保证一个等待线程会进入临界区吗?答案是自旋锁不提供任何公平性保证。实际上,自旋的线程在竞争条件下可能会永远自旋。自旋锁没有公平性,可能会导致饿死。

2.9 比较并交换

1    int CompareAndSwap(int *ptr, int expected, int new) {
2        int actual = *ptr;
3        if (actual == expected)
4            *ptr = new;
5        return actual;
6    }

比较并交换的基本思路是检测ptr指向的值是否和expected相等;如果是,更新ptr所指的值为新值。否则,什么也不做。不论哪种情况,都返回该内存地址的实际值,让调用者能够知道执行是否成功。

如何让锁不会不必要地自旋,浪费CPU时间?

2.13 简单方法:让出来吧,宝贝

如果临界区的线程发生上下文切换,其他线程只能一直自旋,等待被中断的(持有锁的)进程重新运行。有什么好办法

1    void init() {
2        flag = 0;
3    }
4
5    void lock() {
6        while (TestAndSet(&flag, 1) == 1)
7            yield(); // give up the CPU
8    }
9
10   void unlock() {
11       flag = 0;
12   }

在这种方法中,我们假定操作系统提供原语yield(),线程可以调用它主动放弃CPU,让其他线程运行。线程可以处于 3 种状态之一(运行、就绪和阻塞)。yield()系统调用能够让运行(running)态变为就绪(ready)态,从而允许其他线程运行。因此,让出线程本质上取消调度(deschedules)了它自己。

现在来考虑许多线程(例如100个)反复竞争一把锁的情况。在这种情况下,一个线程持有锁,在释放锁之前被抢占,其他99个线程分别调用lock(),发现锁被抢占,然后让出CPU。假定采用某种轮转调度程序,这99个线程会一直处于运行—让出这种模式,直到持有锁的线程再次运行。虽然比原来的浪费99个时间片的自旋方案要好,但这种方法仍然成本很高,上下文切换的成本是实实在在的,因此浪费很大。

更糟的是,我们还没有考虑饿死的问题。一个线程可能一直处于让出的循环,而其他线程反复进出临界区。很显然,我们需要一种方法来解决这个问题。

2.14 使用队列:休眠替代自旋

调度程序决定如何调度。如果调度不合理,线程或者一直自旋(第一种方法),或者立刻让出CPU(第二种方法)。无论哪种方法,都可能造成浪费,也能防止饿死。

因此,我们必须显式地施加某种控制,决定锁释放时,谁能抢到锁。为了做到这一点,我们需要操作系统的更多支持,并需要一个队列来保存等待锁的线程。

1    typedef struct  lock_t {
2        int flag;
3        int guard;
4        queue_t *q;
5    } lock_t;
6
7    void lock_init(lock_t *m) {
8        m->flag = 0;
9        m->guard = 0;
10       queue_init(m->q);
11   }
12
13   void lock(lock_t *m) {
14       while (TestAndSet(&m->guard, 1) == 1)
15           ; //acquire guard lock by spinning
16       if (m->flag == 0) {
17           m->flag = 1; // lock is acquired
18           m->guard = 0;
19       } else {
20           queue_add(m->q, gettid());
21           m->guard = 0;
22           park();
23       }
24   }
25
26   void unlock(lock_t *m) {
27       while (TestAndSet(&m->guard, 1) == 1)
28           ; //acquire guard lock by spinning
29       if (queue_empty(m->q))
30           m->flag = 0; // let go of lock; no one wants it
31       else
32           unpark(queue_remove(m->q)); // hold lock (for next thread!)
33       m->guard = 0;
34   }

在这个例子中,我们做了两件有趣的事。首先,我们将之前的测试并设置和等待队列结合,实现了一个更高性能的锁。其次,我们通过队列来控制谁会获得锁,避免饿死。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值