并发 (一)——基本概念

目录

一、前言

二、基本术语

三、同步

3.1 评估一个锁

3.2 锁的实现

3.2.1 test-and-set 

3.2.2 compare-and-swap / compare-and-exchange

3.2.3 load-linked & store-condition

3.2.4 fetch-and-add

3.2.5 yield

3.2.6 sleeping instead of spinning

3.3 条件变量(condition variable)

3.4 semphore


一、前言

本文是OSTEP笔记,只是对其中第二部分并发相关章节的提炼,以满足自己对基本原理方面的好奇心,作为并发系列总结后续的理论基础。如果有错误,欢迎指出。

二、基本术语

critical section:临界区,访问共享资源(通常是一个变量或者数据结构)的一个代码段。

race condition:竟态,多个执行线程同时进入临界区并试图同时更新共享资源。

indeterminate program:由于竟态导致程序运行结果随机。

mutual exclusion:互斥,保证同一时间只有一个执行线程进入临界区,避免竞争。

atomicity:原子,all or none,一次做完或者什么也不做。

三、同步

需要进行同步操作有以下两种情况:

  1.  多执行线程访问共享资源,需要保护临界区,需要加锁。
  2. 一个线程等待另一个线程完成才能进行下一步的操作,如某线程需要等待I/O操作完成才能进行下一步操作,又如在并行处理中,某核要等待其他核完成相应的计算取得结果之后,才能继续自己的任务。

对于上述第一种情况,可以使用锁来保护临界区,锁的使用方法如下:

acquire the lock

enter critical section 

release the unlock

3.1 评估一个锁

构建一个可用的锁需要下面三个方面的考量:

  • correctness
  • fairness
  • performance

可以看到,锁的演进都是在保证正确的前提下,不断提升其公平性和性能的。

3.2 锁的实现

锁的实现需要硬件提供synchronization primitives,下面通过两个例子说明软件无法实现lock的原因:

例一

typedef struct __lock_t {
     int flag;
}lock_t;

void init(lock_t *mutex) {
    mutex->flag = 0;
}

void lock(lock_t *mutex) {
    while (mutex->flag == 1) 
        ; 
    mutex->flag = 1; // now SET it!
}

void unlock(lock_t *mutex) {
    mutex->flag = 0;
}

程序很简单,通过一个flag标识是否能获得锁,获得锁就置1,这样下一个想要获取锁就需要spin,直到锁被释放,即flag置0。这样的锁的实现是不正确的:假设开始锁的unlocked的,这时一个线程获取锁当执行完while (mutex->flag == 1)时,线程被打断,另一个线程获取到锁,进入临界区,原线程切回来继续执行,注意此时while语句已经满足了条件,继续向下执行,也进入了临界区,因此的锁的实现不正确。

例二:

decker‘s alg

通过以上分析,无法通过软件来实现一个锁。因此锁的实现需要通过硬件提供同步原句。

3.2.1 test-and-set 

int TestAndSet(int *old_ptr, int new) {
    int old = *old_ptr;
    *old_ptr = new;
 
    return old; 
}

上面是test-and-set的C伪码,注意它代表一个原子操作就可以了。整个执行的作用就是保存new的值,返回old_ptr中的旧值。我们定义锁的两个状态:

#define UNLOCK        0

#define LOCK             1

那么使用test-and-set实现自旋锁的代码如下:

typedef struct __lock_t{
    int flag;
}lock_t;

void init(lock_t *lock){
    lock->flag = 0;
}

void lock(lock_t *lock){
    while (TestAndSet(&lock->flag, 1) == 1)
        ; // spin-wait (do nothing)
}

void unlock(lock_t *lock){
    lock->flag = 0;
}

关键的一句就是 while (TestAndSet(&lock->flag, LOCK) == LOCK) ; 

尝试LOCK但是锁已经被LOCK住了(原内存的值已经是LOCK了)就一直循环等待。

3.2.2 compare-and-swap / compare-and-exchange

int CompareAndSwap(int *ptr, int expected, int new) {
    int actual = *ptr;
    if (actual == expected)
        *ptr = new;
    return actual;
}

上面是CAS的的C伪码。和test-and-set不同之处在于只有内存中的值和expected值一致时,才更新new,返回内存原值。

void lock(lock_t *lock) {
while (CompareAndSwap(&lock->flag, 0, 1) == 1)
    ; // spin
}

利用该原句实现锁 while (CompareAndSwap(&lock->flag, UNLOCK, LOCK) == LOCK)

尝试去LOCK,如果没有返回期望的值(UNLOCK)就继续循环等待。

3.2.3 load-linked & store-condition

前面两个原句的原理是test/compare if UNLOCK, set/swap LOCK,本节的指令形式与之不同,具体看:

int LoadLinked(int *ptr) {
    return *ptr;
}

int StoreConditional(int *ptr, int value) {
    if (no one has updated *ptr since the LoadLinked to this address) {
        *ptr = value;
        return 1; // success!
    } else {
        return 0; // failed to update
    }
}

上面时load-linked和store-condition的C伪码,可以看到load-linked和正常load指令类似,store-conditon要检测load-linked加载该地址的内容以后没有被更新过或者被中断打断。如果出现了上述情形就返回错误,表明本次store时失败的,需要再次尝试。

void lock(lock_t *lock) {
while (1) {
    while (LoadLinked(&lock->flag) == 1)
        ; // spin until it’s zero
    if (StoreConditional(&lock->flag, 1) == 1)
        return; // if set-it-to-1 was a success: all done
    
    // otherwise: try it all over again
    }
}

使用load-linked和store-condition实现lock方法如上,原理就是使用load-linked指令并保证当前锁是UNLOCK的,此时使用store-condition将锁的状态置为LOCK,由硬件保证其原子性。

3.2.4 fetch-and-add

int FetchAndAdd(int *ptr) {
    int old = *ptr;
    *ptr = old + 1;
    return old;
}

fetch-and-add 指令C伪码,可以看出就是将ptr对应的值加1,然后返回旧值。

typedef struct __lock_t {
    int ticket;
    int turn;
}lock_t;

void lock_init(lock_t *lock) {
    lock->ticket = 0;
    lock->turn = 0;
}

void lock(lock_t *lock) {
    int myturn = FetchAndAdd(&lock->ticket);
    while (lock->turn != myturn)
        ; // spin
}

void unlock(lock_t *lock) {
    lock->turn = lock->turn + 1;
}

上图使用fetch-and-add实现了一个ticket lock,锁由ticket和turn组成,每个执行lock的线程,都会获得一个id,当这个id和锁的turn相同时,获的锁,进入临界区。这样获取锁的顺序由turn决定,保证了获取锁的fairness。

上述自旋锁会浪费大量的cpu时间,因此临界区要尽可能小,而且有的临界可能会有context switch,这时候使用自旋锁可能会造成死锁,因此要考虑其他锁的实现方法。

3.2.5 yield

void lock() {
    while (TestAndSet(&flag, 1) == 1)
        yield(); // give up the CPU
}

获取不到锁就放弃cpu,相当于把进程状态从running切换到ready

使用yield存在的问题是context switch的开销和饥饿问题。考虑100线程同时获取锁的情况,当有一个获取锁以后,其他的99各线程调用yeild让出cpu,当锁被释放以后,下一个获取锁的线程依赖于系统的调度算法,因此,存在某些线程无法被调度执行的情况,造成饥饿。

3.2.6 sleeping instead of spinning

可以考虑排队的方法来避免饿死

typedef struct __lock_t {
    int flag;
    int guard;
    queue_t *q;
}lock_t;

void lock_init(lock_t *m) {
    m->flag = 0;
    m->guard = 0;
    queue_init(m->q);
}

void lock(lock_t *m) {
    while (TestAndSet(&m->guard, 1) == 1)
        ; //acquire guard lock by spinning
    if (m->flag == 0) {
        m->flag = 1; // lock is acquired
        m->guard = 0;
    } else {
        queue_add(m->q, gettid());
        m->guard = 0;
        park();
    }
}

void unlock(lock_t *m) {
    while (TestAndSet(&m->guard, 1) == 1)
        ; //acquire guard lock by spinning
    if (queue_empty(m->q))
        m->flag = 0; // let go of lock; no one wants it
    else
        unpark(queue_remove(m->q)); // hold lock (for next thread!)

    m->guard = 0;
}

solaris :

park()       使调用的线程睡眠

unpark()   通过id唤醒特定的线程

guard对应的自旋锁对应小的临界区的保护,flag作为锁的标记

lock:

spin_lock(&m->guard);     // (1)

if (m->flag == 0) {  // (2)

     m->flag = 1;  // (3)

     spin_unlock(&m->guard); // (4)

} else {

         queue_add(m->q, gettid());  // (5)
         spin_unlock(&m->guard) //  (6)
         park(); // (7)

}

unlock:

    spin_lock(&m->gurad);          //(7)
    if (queue_empty(m->q))        //(8)
        m->flag = 0;                       //(9)

    else
        unpark(queue_remove(m->q)); // hold lock (for next thread!) (10)

  spin_unlock(&m->gurad);

TIPS:

1. 为何 (6), (7) 不能互换,考虑一下先park后spin_unlock, 这样会导致死锁,因为guard锁没有释放,后续任何调用lock和unlock的线程都会spin,造成死锁。

某进程运行在(6), (7) 之间,即park()之前,如果被持有锁的进程打断,并释放掉锁,那么前一个进程可能会永远睡眠(?)。

解决的方法是将park()替换为set_park(),set_park()会声明自己即将进入睡眠状态,如果它恰好被中断且另一个进程要在其park()之前执行unpark(),前一个进程的park()操作会立即返回而不是进入睡眠。

A different solution could pass the guard into the kernel. In that case,
the kernel could take precautions to atomically release the lock and dequeue
the running thread.

2. 为何(10)处只有unpark,而没有解锁(m->flag = 0),这样做是必要的,当进程从等待队列移除时,被唤醒的进程从park()后开始执行,这时候需要持有锁,此过程相当于锁从上一个解锁者直接传递给下一个锁的持有者。

TODO: linux futex

TODO: Two-Phase Locks  先尝试一定次数的自旋,如果没有获取到锁,睡眠。

3.3 条件变量(condition variable)

在许多情形下,一个线程要等到某个条件成立才可以继续向下执行,如主线程要检测子线程结束后才继续执行(join动作),这时候不能用一个简单的变量去标识条件,这样做是低效的并且在某些场景下是错误的。这种场景下要使用条件变量。

一个条件变量是一个显式的队列,当线程执行种的某些状态(条件)不满足预期时,线程就把自身置于队列中(wating on the condition )。当其他的线程改变了这些条件,可以唤醒这些等待的线程(signaling on the condition),这样可以允许他们继续执行。

条件变量有两个基本操作wait() 和signal():线程需要睡眠时调用wait,线程更改了某些条件,想要唤醒在该条件上等待的线程时调用signal,在POSIX中:

pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m);

pthread_cond_signal(pthread_cond_t *c);

wait通常有一个mutex参数,wait原子的执行解锁和睡眠操作(?),当线程在条件满足下被重新唤醒时,返回之前仍要先获得锁。这种处理上的复杂性源于避免线程睡眠时可能存在的竟态。

一般的,条件变量的使用方式如下:

void thr_exit() {
    Pthread_mutex_lock(&m);
    done = 1;
    Pthread_cond_signal(&c);
    Pthread_mutex_unlock(&m);
}

void thr_join() {
    Pthread_mutex_lock(&m);
    while (done == 0)
        Pthread_cond_wait(&c, &m);
    Pthread_mutex_unlock(&m);
}

下面展示几种错误的方式增加理解

void thr_exit() {
    Pthread_mutex_lock(&m);
    Pthread_cond_signal(&c);
    Pthread_mutex_unlock(&m);
}

void thr_join() {
    Pthread_mutex_lock(&m);
    Pthread_cond_wait(&c, &m);
    Pthread_mutex_unlock(&m);
}

如果不使用变量done可不可以呢,不可以。因为如果thr_exit先执行,thr_join后执行会无条件睡眠,无法被唤醒。这个例子可以看出状态变量done是十分有用的。

void thr_exit() {
    done = 1;
    Pthread_cond_signal(&c);
}

void thr_join() {
    if (done == 0)
    Pthread_cond_wait(&c);
}

如果不使用mutex可不可呢,也不可以,因为如果thr_join先执行,满足条件,当要执行睡眠操作的时候被打断,切换到thr_exit执行,这样当thr_join切换回来睡眠后就无法被唤醒了。这里其实需要mutex保护变量done,避免竟态。

TIPS:

hold the lock when calling signal or wait

保证使用条件变量一定要持有锁,因为变量是共享资源。

生产者消费者问题 (The Producer/Consumer (Bounded Buffer) Problem)

由Dijkstra首次提出,通过这个问题,发明了通用的信号量(semaphore)——可以被用作锁或者条件变量。下面通过对一个简单的生产者消费者程序的完善,说明条件变量的使用方法。

int buffer;
int count = 0; // initially, empty

void put(int value) {
    assert(count == 0);
    count = 1;
    buffer = value;
}

int get() {
    assert(count == 1);
    count = 0;
    return buffer;
}

void *producer(void *arg) {
    int i;
    int loops = (int) arg;
    for (i = 0; i < loops; i++) {
        put(i);
    }
}

void *consumer(void *arg) {
    int i;
    while (1) {
        int tmp = get();
        printf("%d\n", tmp);
    }
}

上面是该模型的初始代码,很简单,就是producer和consumer批量向一个只能保存一个int的buffer中存/取数据。我们知道,这个缓冲区是共享资源,对其更新的代码是临界区。我们使用条件变量实现保护:

int loops; // must initialize somewhere...
cond_t cond;
mutex_t mutex;

void *producer(void *arg) {
    int i;
    for (i = 0; i < loops; i++) {
        Pthread_mutex_lock(&mutex); // p1
        if (count == 1) // p2
            Pthread_cond_wait(&cond, &mutex); // p3
        put(i); // p4
        Pthread_cond_signal(&cond); // p5
        Pthread_mutex_unlock(&mutex); // p6
    }
}

void *consumer(void *arg) {
    int i;
    for (i = 0; i < loops; i++) {
        Pthread_mutex_lock(&mutex); // c1
        if (count == 0) // c2
            Pthread_cond_wait(&cond, &mutex); // c3
        int tmp = get(); // c4
        Pthread_cond_signal(&cond); // c5
        Pthread_mutex_unlock(&mutex); // c6
        printf("%d\n", tmp);
    }
}

上面的程序有两个问题:

第一,c2,c3处,执行wait 时使用if对状态变量进行判断,这样是有问题的,假设consumer1先运行,wait处睡眠等待,之后producer put,唤醒consumer1,再consumer1重新获取锁之前,consumer2先获得锁并进行了get操作,切换回consumer1依然会再进行一次get操作,这是不正确的。这说明,当wait被唤醒是无法保证原条件是满足的,因为这里有一个锁的释放与重新获取的过程,有一个切换窗口,修改的方法很简单,将if条件判断修改为whille条件判断。

第二,假设有两个consumer,并在一开始就运行,都会进入睡眠状态,这时候producer 进行put操作,调用signal唤醒一个consumer,并再次尝试写入时进入睡眠状态,这时consumer被唤醒,执行get后调用signal,如果此时另一个consumer被唤醒,但此时没有数据重新进入睡眠。这时候三个线程都处于睡眠状态!问题出现的原因在于条件变量使用混乱,consumer不能唤醒其他的consumer,只能唤醒producer。通常,wait/signal处理的条件是成对出现的,在本例中

producer wait on condition full, signal (wake up)  empty condition.

consumer wait on condition empty,signal(wake up) full condition .

这里还是总结一下条件变量的使用原则:

  • 使用wait和signal时要持有锁。
  • wait被唤醒时,不能对此时的状态变量有任何假设,所以需要用while判断。同时能防止spurious wakeups(惊群)问题
  • wait/signal的条件要一致——wait on condition not satisfied,signal conditon satisfied。

另一种场景是

int bytesLeft = MAX_HEAP_SIZE;
cond_t c;
mutex_t m;

void * allocate(int size) {
    Pthread_mutex_lock(&m);
    while (bytesLeft < size)
        Pthread_cond_wait(&c, &m);
    void *ptr = ...; // get mem from heap
    bytesLeft -= size;
    Pthread_mutex_unlock(&m);

    return ptr;
}

void free(void *ptr, int size) {
    Pthread_mutex_lock(&m);
    bytesLeft += size;
    Pthread_cond_signal(&c); // whom to signal??
    Pthread_mutex_unlock(&m);
}

这是一个内存分配系统allocate不满足条件就wait,free时唤醒,这里有个问题时无法知道唤醒哪各线程,加入两个线程分别申请100和10各字节,此时没有额外内存,睡眠。这时候有一个free释放了50个字节,那么此时去唤醒哪个线程呢,唤醒申请100字节的显然唤醒了也没用,这种情况下使用signal函数时不行的,需要使用broadcast,唤醒所有的等待线程,让他们去竞争。

3.4 semphore

目前我们可以使用锁和条件变量去解决两类的并发问题,semphore可以同时作为锁和条件变量使用。

看一下信号量的定义:

int sem_wait(sem_t *s) {
    decrement the value of semaphore s by one //将s->value减一
    wait if value of semaphore s is negative  //如果此时value为负值,wait
}

int sem_post(sem_t *s) {
    increment the value of semaphore s by one  //将s->value加一
    if there are one or more threads waiting,  //如果此时有一个或者多个线程等待
    wake one ,                                //唤醒一个
}

信号量实现锁(Binary Semaphores)

sem_t m;
sem_init(&m, 0, X); // initialize semaphore to X; what should X be?

sem_wait(&m);
     // critical section here
sem_post(&m);

根据定义,将X值初始化为1,可以实现锁。

信号量实现时间排序(event order,类似条件变量)

根据定义,将s->value值初始化为0,可以实现条件变量。

四、参考

【1】 OSTEP

 

 

 

 

 

 

 

 

 

参考:

【1】Operating Systems: Three Easy Pieces

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值