首页 > 其他 > 详细

操作系统并发的一些知识点梳理

时间:2021-02-15 23:29:29      阅读:25      评论:0      收藏:0      [点我收藏+]

并发无论是在操作系统层面还是在编程语言层面,都是一个极为重要的概念。线程(thread)是对并发的一种抽象,经典观念认为一个程序只有一个执行点(一个程序计数器,用来指向要执行的指令)。但是多线程(multi-thread)程序会有多个执行点(多个程序计数器)。换个角度来看,线程的概念类似于进程,有别于进程的地方就是多线程环境下,每个线程他们要共享地址空间,不同线程之间能够访问到共同的数据。

线程与进程十分相似,但又不同,进程是分时操作系统最早提出的一种任务调度模型。进程的出现使得操作系统拥有更好的交互性和更高的效率。在操作系统中,每个进程都有自己独立的地址空间,各个进程之间相互隔离,互补干扰。

而线程可以看做是更细粒度的一种进程。但是线程必须依赖于进程存在,没有独立于进程的线程。进程是操作系统分配资源的最小单位,线程是操作系统调度的最小单位。

现代分时操作系统中大部分操作系统都支持线程,线程成为了CPU,操作系统调度的最小单元。然而线程不仅仅局限于操作系统,线程这个抽象的概念也可以被程序设计语言去实现。所以按照线程的实现者的不同可以将线程分为两类:

  1. 用户线程:有程序设计语言实现(软件实现),不依赖于操作系统。
  1. 内核线程:操作系统实现,操作系统负责调度。

有关进程

进程:一个具有独立功能的程序在数据集合上的一次动态执行过程(进程的学术定义)

进程这个概念与程序,或者我们的代码有很大的联系。我们写出的代码最终要变成计算机可以识别的二进制语言存储于内存中,进程可以看做是代码的一次动态执行。有人说,程序=数据结构+算法。这种说法完全正确,但也可以退化来看:程序=数据+指令。所以进程就可以看做是数据和指令在计算机内的一次运行。

所以进程与程序的关系大致如下:

  1. 程序是产生进程的基础。
  2. 程序每次运行构成了不同的进程。
  3. 进程是程序功能的体现。
  4. 一个程序可以对应多个进程,通过调用关系,一个进程又可以包括多个程序。

同时进程与程序的区别大致如下:

  1. 进程是一个动态的概念。程序是一个静态的概念。程序是有序指令的集合,进程是程序的一次执行。
  2. 进程具有一定的时效性,它的运行周期可预期。

所以进程可以看作是程序的实例,程序可以看作是进程的模板。

进程的组成

  • code 代码
  • data 数据
  • PCB(Process Control Block) 进程控制块

进程的特点

  • 动态性:进程可以被动态创建,也可以动态结束。
  • 并发性:进程可以被独立调度,并占用处理机运行。
  • 独立性:不同进程之间是相互隔离,互不影响的。
  • 制约性:因访问共享资源(数据)或进程间同步而受到制约。

PCB的构成

  • 进程标识信息
    • 本进程标识
    • 父进程标识
    • 用户标识
  • 处理器状态信息
    • 用户可见寄存器
    • 控制和状态寄存器:PC,PSW
    • 栈指针
  • 资源信息

进程的生命周期

技术分享图片

有关线程

线程是进程中的一条流程。从资源组合角度来看:进程把一组相关资源组合起来,构成了一个资源平台(环境),包括地址空间(代码段,数据段),打开的文件等各类资源。从运行角度来看,代码在这个资源平台上执行的一个流程称为线程。

技术分享图片

线程模型的优缺点

  • 优点
    • 一个进程可以存在多个线程(同时存在)。
    • 各个线程之间可以并发地执行。
    • 各个线程之间可以共享地址空间和文件资源。
  • 缺点
    • 一个线程的崩溃,有可能导致其所属进程的所有线程崩溃。

进程是资源分配的单位,线程是CPU调度的单位。进程拥有一个完整的资源平台,而线程只独享必不可少的资源,入寄存器和栈。同时线程具有与进程类似的五种状态,但是线程比较轻量能够减少并发时间和开销,线程的轻量级主要体现在如下方面:

  1. 线程的创建时间很短。
  2. 线程的终止时间很短。
  3. 同一进程内线程的切换时间很迅速。
  4. 同一进程内不同线程之间共享内存和文件等系统资源。

用户线程和内核线程

  1. 用户线程:用户线程是操作系统无法感知的线程,它不是由操作系统创建、调度、管理。不依赖于操作系统内核,它由一组用户级别的库函数完成,通过用户线程可以在不支持多线程模型的操作系统之上完成多线程编程。同时,用户线程的切换无须经过操作系统内核,所以它的切换会很快,同时用户还可以自己DIY线程的调度算法。但是用户态线程也有缺点,如果用户线程发起一个阻塞的系统的调用,那么它会阻塞整个进程内的所有用户线程。同时操作系统将时间片分给了进程,而没有直接分给线程,所以平均每个线程的执行时间会比较短,因此用户态线程执行起来会比较慢。
  2. 内核线程:操作系统内核中实现一种机制(线程机制),由操作系统负责创建、调度、管理线程,使用者仅需发出线程创建相关的系统调用即可。但是内核线程的创建会经历用户态到内核态的转变,所以开销比用户线程大,但是内核线程由操作系统管理,因此当其中一个线程发生阻塞时,并不会影响到同进程内其他线程的工作,同时内核线程分得的CPU时间较多,执行效率较高。

C语言环境进程创建代码


#include <stdio.h>

#include <pthread.h>

/* 线程任务函数 */

void *mythread(void *args) {

    printf("%s\n", (char*) args);

    return NULL;

}

int main() {

    pthread_attr_t p1Attr; /* 线程的属性 */

    pthread_t p1; /* 线程 */

    int rc;

    pthread_attr_init(&p1Attr); /* 初始化线程属性 */

    pthread_attr_setscope(&p1Attr, PTHREAD_SCOPE_SYSTEM); /*与操作系统绑定*/

    pthread_attr_setschedpolicy(&p1Attr, SCHED_RR); /* 轮询的方式进行调度 */

    puts("Hello world\n");

    rc = pthread_create(&p1, &p1Attr, mythread, "A"); 

    puts("Start a new thread\n");

    rc = pthread_join(p1, NULL); 

    return 0;

}

pthread是POSIX Threads的简称。

POSIX,可移植操作系统接口(英文:Portable Operating System Interface)POSIX是IEEEE为要在各种UNIX操作系统上运行软件,而定义的一系列操作系统API接口,正式名称为IEEEE Std 1003,国际标准化组织名称

ISO/IEC 9945。 目前Linux基本上逐步实现了POSIX的兼容,但并未获得正式的POSIX认证。微软的Windows NT声称实现了部分POSIX标准。当前POSIX主要分为四部分:Base Definition、System Interfaces、Shell and Utillities、Rationale。

在Linux环境中,你可以使用<pthread.h>结合libpthread.so来创建线程,在Windows下可以使用MinGW结合pthread来创建线程,当然也可以使用<windows.h>中的windows API来创建线程,只不过<pthread.h>显得更加标准和易使用,但需要平台和工具的支持。

如你所见,线程的创建有点类似于函数的调用,然而,并不是首先执行函数然后返回给调用者,而是为调用的例程创建一个新的执行线程,它可以独立于调用者运行,至于函数什么时候被调用完全取决于操作系统(相应库函数的调度策略)。开玩笑的说:如果一个程序员遇到了一个问题,他想要用多线程去解决,那么他将面临两个问题。

那么使用多线程并发会带来哪些问题呢?

并发带来的问题

并发固然可以提高程序的运行效率。但是同样也带来了许多沉重的代价,例如:

  1. 共享数据问题。
  2. 并发同步问题。
  3. BUG不易复现问题。

共享数据问题


#include <stdio.h>

#include <pthread.h>

static int counter = 0; /* 全局变量 */

/* 对变量counter进行递增操作 */

void *decrement(void *args) {

    printf("In thread %s\n", (char*)args);

    int i;

    for (i=0; i<100000; ++i)

        counter--;

    return NULL;

}

/* 对变量进行递减操作 */

void *increment(void *args) {

    printf("In thread %s\n", (char*)args);

    int i;

    for (i=0; i<100000; ++i) 

        counter++;

    return NULL;

}

int main() {

    pthread_t p1,p2;

    int rc;

    rc = pthread_create(&p1, NULL, decrement, "DECREMENT");

    if (rc != 0) {

        printf("thread [DECREMENT] create error!\n");

        exit(-1);

    }

    rc = pthread_create(&p2, NULL, increment, "INCREMENT");

    if (rc != 0) {

         printf("thread [INCREMENT] create error!\n");

        exit(-1);

    }

    pthread_join(p1, NULL);

    pthread_join(p2, NULL);

    printf("counter = %d\n", counter);

    return 0;

}

上述C语言代码逻辑很简单,一个线程对变量counter进行循环递增操作,另一个线程对变量进行循环递减操作,因为循环的次数是一样的,所以我们预期的结果是,最终counter的值不会改变。但是实际运行结果并不是这样。

上述代码执行结果的输出具有不确定性。

从运行结果来看,这并不符合我们的预期,而且大大超出了我们的预期,因为多次运行,结果却还不尽相同。

线程上下文和原子性

之所以会产生这样的结果,根本原因在于线程在运行时处于不可控状态。也就是说,你无法确定某一时刻某个线程是否在运行。当我们创建好线程之后,线程的执行与调度将交由操作系统,我们无法管理我们的线程。

线程的调度,一般采用时间片轮转算法进行调度,即给一个线程分配一定的执行实行例如2ms,2ms之后操作系统会将这个线程当前运行的状态保存到TCB(Thread Control Block,主要用于调度中恢复线程的执行现场), 这个TCB也称为线程上下文。

正如我们所说,一个线程什么时候被执行,什么时候被挂起完全取决于操作系统,那么当线程用完CPU时间片时,线程函数中代码停止的位置也具有一定的随机性。但是这种随机性是导致出现共享数据问题的原因吗?

答案是:不全是。导致共享数据问题的原因不仅仅在于线程的调度,还取决于指令的原子性。我们写的高级语言代码最终要编译为二进制数据保存于内存中,那么我们在高级语言中可以通过一行(一句)代码完成的事情,真正交给CPU去做的时候,可能需要好几个步骤。

例如上述代码中的counter++counter--。这两句代码看起来好像是一步就可以完成,但是CPU真正去执行的时候并不是。我们可以通过gcc -S [source file]的方式,去查看编译后的汇编代码。


movl	counter(%rip), %eax

subl	$1, %eax

movl	%eax, counter(%rip)

通过汇编代码我们可以看到,counter--需要三个步骤才可以完成。同时也要注意,我们说代码停止运行的位置具有随机性,这个位置是对于最终的机器指令来说的。而不是针对于源代码来说。

我们看到CPU在执行的时候,首先它要讲counter从内存中转移至寄存器中。然后对寄存器中的值加上立即数1,然后再将加1之后的寄存器中的值转移至内存中。

我们可以将上述三个步骤分别用LOADCALCSTORE来代替。问题出现的关键点便在于,我们对数据进行CALC之后是否能及时的STORE至内存中,也就是,现在内存中的值,是否是一个最新的值(合理的值)。如果现在CALC之后,未来得及进行STORE操作就移交了CPU 的使用权,那么其他线程读取到的值,就不是一个合理的值。

那么什么是原子性,原子性就是我们期望事件不可再分。例如一条指令,我们期望他不会被分解为其他若干条指令。而是一次性,作为一个基本单元的去执行,并且在执行过程中不可能被中断。

上述代码的问题就在于,我们把counter++counter--误以原子指令的形式去运行。

值得注意的是,有时候一条汇编指令并不一定代表一条原子指令。即汇编指令也不能保障原子性。原子性的保障还需依靠硬件系统的微指令来保障

竞态条件与临界区

在多线程并发的环境下,多个线程在竞争着对同一资源对象进行操作,那么这两个线程将处于竞态条件(Race Condition),竞态条件下执行的代码结果依赖于并发执行或者事件的顺序,这种结果往往具有不确定性和不可重现性。

临界区(Critical section) 是指进程中一段需要访问共享资源并且当另一个进程处于相应代码区域时便不会执行的代码区域。简单说,临界区就是访问共享变量的代码段,这个代码段一定不能被多个线程同时执行。

临界区的特点

  • 互斥性:同一时间,临界区最多只有一个线程进行访问。
  • Progress:如果一个线程想要进入临界区,那么它最终会成功。
  • 有限等待:如果$线程_i?$出入临界区入口,那么$线程_i?$的请求被接受之前,其他线程进入临界区时间是有限制的。
  • 无忙等待:如果一个线程在等待进入临界区,那么在此之前它可选择无忙等待。(Optional)

临界区是一种逻辑概念。那么针对于临界区的性质,有三种实现策略

  • 基于硬件中断的实现。
  • 基于软件
  • 更深层次的抽象

技术分享图片

基于中断的临界区实现

在分时操作系统中,没有时钟中断,就没有上下文切换,就没有并发。操作系统的调度器的实现就是依赖于时钟中断。那么我们在实现临界区的时候,可以在一个线程进入临界区代码后主动禁用掉CPU对中断的响应,在线程离开临界区代码后,再开启CPU对中断的响应。这种实现可以实现良好的互斥性和其他临界区的特性。

但是这种实现并不是最好的实现,因为禁用CPU中断带来的开销非常大。一旦CPU中断响应被禁止,那么不仅仅是其他线程无法被调度,甚至一些基本的设备请求,网络请求等都会受到影响。而且一旦我们临界区代码的开销也同样巨大,那么这种实现的效果就会很差。换言之,这种实现的粒度太大了。

同时这种实现只能作用于单核CPU,对于多核CPU,就不能保障临界区的特性了。

基于软件的实现

基于软件的实现,就是利用一下数据结构+算法,来实现临界区的功能。

例如Bakery算法:


do{

    flag[i] = TRUE

    turn = j

    while (flag[i] && turn == j);

    	进入临界区

    flag[i] = FALSE

    	离开临界区

}while(TRUE)

相对比基于中断的实现方式,基于软件的实现能够达到一种细粒度的控制。但是基于软件实现的方式会很复杂。

更深层次的抽象

锁和信号量。它们是操作系统提供的更高级的编程抽象用来解决临界区问题。锁和信号量不仅能够解决共享数据问题,同时他也可以解决线程间同步的问题,同时可以将我们代码的稳定性提高,降低出现BUG的风险。这两个概念十分重要,它们是解决并发问题的关键,在下面的章节中会详细的介绍。

这种更高层次的抽象,并不是上述两种实现方法的Next Generation。而是借鉴了上述两种实现方式之后的一个更为通用和抽象的解决方案。

锁(Lock)

之前章节我们讲过并发带来的一个基本问题——共享数据。出现这个问题的原因与指令执行的原子性有关(具体有关原子性的概念可以参照之前讲过的共享数据问题的哪一章节)。显然,单纯从指令的原子性上去避免共享数据问题有很大的难度,因为这个需要依赖于我们的硬件系统,需要硬件系统支持。

既然如此,那么应该选用哪种方法既不依赖于硬件,还可以让我们的代码原子性的去运行呢。我们可以从软件层面借助于一种数据结构去实现。这个数据结构便是锁。

锁是对于临界区的一种实现,锁本质上是一个数据结构。在编程中使用它,你可以像使用变量一样去使用。锁为程序员们提供了细粒度的并发控制。之前的章节我们讲过,线程是由程序员创建,由操作系统调度的。换言之,我们创建了线程之后交给了操作系统我们就丢失了对线程的控制权。锁这样的一个数据结构能够在线程调度方面帮助程序员们“曲线救国”。

技术分享图片

如何去实现一个锁

既然锁是对于临界区的一种实现,那么锁就应该具备临界区的基本要求。可以这么讲,任何锁都具备互斥性,这是临界区的基本要求。那么什么是互斥性?互斥性就是在涉及到对共享的变量进行操作的代码时,我们必须保证只有一个线程在操作,而且这个线程必须执行完毕临界区内的所有代码才可以让出临界区交给下一个线程处理。

锁的实现不仅仅只是软件层面的实现,当然仅靠软件(编写代码)去实现锁也可以,但是这样实现的锁不是一个最佳的锁。如果想要实现一个表现良好的锁一定程度上还需要依赖于硬件系统。所以,一个表现良好的锁是软硬结合去实现的。

如何去评价锁

我们说到表现良好的锁,何为表现良好,怎么去评价。换言之,一个表现的锁体现在哪些方面上。

  1. 互斥性:最基本的条件,一个锁是否可以阻止多个线程进入临界区。
  2. 公平性:当锁可用时,是否每个线程有公平的机会去抢到锁,是否保障每个线程都有机会进入临界区。
  3. 性能:锁应用于高并发的场景,然而并发的初衷是为了提高效率,如果使用锁带来了很大的开销,那就类似于舍本逐末,买椟还珠了。

实现一个锁

正如上图所示,当一个线程获得锁之后,他可以执行临界区中的代码。而没有获得锁的线程只能排队,直到获取到锁才可以执行临界区的代码。这样的设计保障了良好的互斥性。那么应该如何去实现呢。

我们可以用一个变量(flag)来标志锁是否被某个线程占用。

  1. 当第一个线程进入临界区后,它要把这个标志位设为1。
  2. 当一个线程想要进入临界区时,它首先要检查这个标志位是否1。如果是1那么证明锁被某个线程占用,所以它要等待锁。
  3. 当线程执行完临界区的代码时,它要将标志位设为0,释放锁的的所有权,以便其他线程使用。

typedef struct lock_t {int flag;} lockt_t;

void init(lock_t *mutex) {
   mutex->flag = 0; /* 初始状态为0 代表锁未被任何线程持有*/
}

void lock(lock_t *mutex) {
    /* 自旋等待 */
    while (mutex->flag != 0); // spin-wait
    mutex->flag = 1;
}

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

/* thread code */
static lock_t mutex;
static int counter = 10;

{
    init(&mutex);
}

void decrement() {
    /* 尝试进入临界区 */
    mutex->lock();
    /* 进入临界区 */
    counter++;
    /* 临界区代码执行完毕,释放锁 */
    mutex->unlock();
    /* 退出临界区 */
}

这样实现的锁有问题吗?,我们可以测试一下。


static lock_t mutex;
static int counter = 0;
const static int LOOP_CNT = 10000;
void decrement() {
    counter--;
}
void increment() {
    counter++;
}
void *threadI(void *args) {
    printf("thread %s\n", (char*)args);
    int i;
    for (i = 0; i < LOOP_CNT; ++i) {
        lock(&mutex);
        increment();
        unlock(&mutex);
    }    
    return NULL;
}

void *threadD(void *args) {
    printf("thread %s\n", (char*)args);
    int i;
    for (i = 0; i < LOOP_CNT; ++i) {
        lock(&mutex);
        decrement();
        unlock(&mutex);
    }
    return NULL;
}

int main() {
    pthread_t th1,th2;
    init(&mutex);
    pthread_create(&th1, NULL, threadI, "threadI");、
    pthread_create(&th2, NULL, threadD, "threadD");
    pthread_join(th1, NULL);
    pthread_join(th2, NULL);
    printf("counter = %d\n", counter);
    return 0;
}

结果出现了点小意外。

技术分享图片

虽然这种状况出现的概率很小,但是出现即意味着我们在代码设计上有问题?那么问题出在了哪里呢。

问题便是,我们的锁也是一个共享的变量,在并发场景下同样会出现共享变量问题。也就是说我们对锁进行操作的代码在CPU看来同样不具备原子性。在我们实现锁的代码中,在对flag标识为进行赋值时,如果操作系统调度中断,那么很有可能出现两个线程同时将flag设置为1,同时拥有锁的现象。显然这连基本的互斥性都无法满足,那么这将是一个bad lock。那么应该怎么做,这就不得不依赖我们的硬件原语了。

test-and-set

test-and-set是一种硬件原语。这种硬件原语能够保障指令的原子性。在SPARC上,这个指令叫做ldstub(load/store unsigned byte) 加载保存无符号字节。在x86平台上,是xchg(atomic exchange, 原子交换指令)

因为这是一个硬件方面的原语,我们只能以C代码的形式来定义一下这个硬件原语做了什么


int test_and_set(int *oldptr, int new) {
    int old = *oldptr;
    *oldptr = new;
    return old;
}

我们用test-and-set这个硬件语言去重新实现一下我们的锁


void lock(lock_t *mutex) {
 /* 这段代码可以保证flag设置的原子性 */
    while (test_and_set(&mutex->flag, 1) == 1); // spin-lock
}

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

自旋锁

我们通过上述C语言代码实现了一个简单的锁,其实这种锁还有一个名字——自旋锁。这种锁的实现简单,实现思路也非常的清晰明了,但是这个锁是一个表现良好的锁吗? 在互斥性上,自旋锁能够做到良好的互斥性。但是从开销方面来看,这个锁并不是一个表现良好的锁。为什么这么说呢?因为自旋锁并没有真正的让其他线程去等待,用一个更为确切的词语说,自旋锁的策略是让其他线程阻塞。 事实上,上述临界区代码的执行过程中,没有获得锁的线程同样会获得操作系统分给它的CPU时间片。只不过它阻塞在lock的while循环上,这种操作有点浪费CPU的资源,因为它在这段时间内什么都没做,只是在不断的循环,直至把CPU时间片耗光,然后等待操作系统将CPU的使用权分配给其他线程,我们也称这种等待为有忙等待。 ### 自旋锁的后话 我们通过一个简单的数据结构+硬件原语的支持实现了一个简单的锁。这个锁具有良好的互斥性,但是我们诟病了这个锁的资源浪费。那么自旋真的就是不好的现象吗?事实上,自旋并非一种坏事,任何事物要评价它的好与坏,一定程度上也要看事物作用的环境。有些场景下,自旋的确是一个不错的选择,例如Linux系统中有一种叫做两阶段锁的自旋锁(two-phase-lock),两阶段锁就意识到自旋可能很有用,尤其是在一些临界区代码很少,(如果临界区代码很少,一个线程一个CPU时间片内就能执行完代码,并且释放锁,那么在CPU时间片分给下一个线程时,下一个线程会立即进入临界区并执行,这样看来,自旋锁的效率会很高,也没有造成资源的浪费,或者在多核CPU的情况下,自旋锁在某些场景下也有不错的表现)线程很快会释放锁的场景。因此两阶段锁的第一阶段先会自旋一段时间,希望它能够获取到锁。 自旋锁的实现中,除了硬件原语test-and-set,还有一些其他的硬件原语也可以帮助自旋锁进行原子性的加锁操作。例如:compare-and-swap(CAS, 比较并交换),LL/SC(链接加载和条件式存储指令)。 除此之外,还有一种公平的自旋锁实现——ticket锁。ticket锁依赖于一个叫做fetch-and-add(获取并增加)的硬件原语去实现,如果有兴趣可以查阅相关资料了解ticket锁的实现。

非自旋锁的简单实现

既然自旋锁的确有一些不可避免的开销,那么我们如何去实现一个“完美”的锁呢?既然没有获得锁之前,线程会一直自旋等待,那么有没有办法消除这种自旋等待呢?最简单的办法就是如果我不能获取到锁,我就跳出循环,这样不就不会自旋了吗?然而,我们跳出了循环,还必须要保障跳出循环之后不能进入临界区,这就有点棘手了。好在操作系统为我们提供了一个API——yield。 yield的这个API的历史很是久远,最初它的设计初衷是为了便于操作系统进行多任务的调度,所以期望程序员们在编程时可以在一些代码段中添加yield,一旦执行了yield函数,操作系统就会获得CPU的使用权,进而操作系统会暂时中止掉当前的进程,转而调度其他进程,这样可以时多个进程并发的执行,提高操作系统的交互性和进程响应时间。但是这种设计无疑是加重了程序员们的负担。所以这种方法最终没有应用于操作系统的任务调度器上。 尽管yield没有被用于任务调度器,但是对于当前我们面临的问题似乎是一个很好的方案,我们可以在获取不到锁的时候调用yieldAPI,进而转交控制权给操作系统,让操作系统继续调度其他线程,这样看来只需对代码进行少量修改,就能对原来的自旋锁进行一个不错的优化。

void lock(lock_t *mutex) {      
    while (test_and_set(&mutex->flag, 1) == 1)           
    yield();  
}

然而事实上,这种实现也不是一个很好的方案,为什么这么说呢?尽管这样避免了无意义的循环操作,但是这会让操作系统陷入一种频繁切换线程上下文的操作,这种操作的开销也十分巨大。

假如我们当前有100个线程,只有一个线程获取到了锁,那么操作系统在最坏的情况下要进行99次的线程上下文切换操作才可以重新将CPU使用权交给当前拥有锁的线程。

这样的实现虽然避免了自旋,但又让线程进入了一种频繁的进入-跳出操作,又让操作系统执行了巨大的开销。

使用队列

既然自旋和yield都不是一个很好的选择,那么我们可以选择使用队列的方式的进行。


typedef struct lock_t {
    int flag;
    int guard;
    queue_t *q;
} lock_t; /* 使用队列的锁 */

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

void lock(lock_t *mutex) {
      /* 自旋锁 */
    /* 锁的作用是保障只有一个线程完成队列的添加&线程休眠工作和获得锁操作 */
    while (test_and_set(&mutex->guard, 1) == 1);
    if (mutex->flag == 0) {
        /* 当前还有线程获得锁 */
        mutex->flag = 1;
        mutex->guard = 0;
    } else {
        /* 已有线程获得锁,将此线程的ID号加入到等待队列中,并休眠 */
        queue_add(mutex->q, gettid());
        m->guard = 0;
        park(); /* 线程休眠 */
    }
    /* 从这里可以看出,自旋锁在针对比较小的临界区时,是很有效的 */
}

void unlock(lock_t *mutex) {
    /* 如果有线程正在尝试加锁,那么要阻塞 */
    while (test_and_set(&mutex->guard, 1) == 1);
    if (queue_empty(mutex->q))
        mutex->flag = 0; /* 当前队列中没有线程想要获得锁,所以可以释放 */
    else
        /* 当前队列中有线程想要获得锁,所以唤醒一个线程即可 */
        /* 这里无需做锁的释放操作,原因是park()API的使用特性,下面会做详细讲解 */
        unpark(queue_remove(mutex->q)); /* 唤醒一个线程 */
    mutex->guard = 0;
}

parkunpark同样是操作系统API,Solaris系统提供了这两个系统调用。这两个系统调用的作用:

  1. park,让线程睡眠。线程的状态将处于阻塞状态,一旦线程睡眠,他将不会获得操作系统调度,直到被唤醒。

    同时当线程被唤醒时,被唤醒的线程会继续从park()函数所在位置开始执行。这也就是我们在上述代码中唤醒线程之后而不用释放锁的原因。

  2. unpark,唤醒指定的睡眠的线程。

上述代码设计中其实有一个漏洞,那就是park操作不是原子的。也就是说,当一个线程被当前获得锁的线程唤醒时,他要从park函数开始执行,假如此时发生了中断,操作系统要切换线程,那么就会导致当前正在执行唤醒操作的线程永远的睡眠下去。但是我们也不能将park操作放入由mutex-guard确定的自旋锁中,这样会导致死锁问题。不过,Solaris操作系统意识到了这一点,它提供了一个新的setparkAPI帮助我们解决了这一原子性问题。


void lock(lock_t *mutex) {
    /* 尝试进入临界区 */
    while (test_and_set(&mutex->guard, 1) == 1);
    if (mutex->flag == 0) {
        mutex->flag = 1;
    mutex->guard = 0; /* 退出临界区 */
    } else {
        queue_add(mutex->q, gettid());
    m->guard = 0; /* 退出临界区 */
        setpark(); 
    }
}

信号量

信号量是一个整型数值的对象,它也是一种数据结构。这个数据有结构有两个基本的原子操作:

  1. P操作,对信号量整形数值进行减一操作,如果结果小于0,那么将会阻塞。
  2. V操作,对信号量整形数值进项加一操作,如果数值小于等于0,唤醒一个等待的线程。

技术分享图片

技术分享图片

信号量这个概念是由一个出色的计算机科学家Dijkstra提出的,没错,他就是提出那个最短路劲算法的大神。

信号量看起来和我们说的锁,有几分相似,事实上,信号量能够完成锁的功能,而且信号量还能完成锁不能完成的功能。我们可以根据功能将信号量分为以下几类:

  • 互斥信号量,信号量的整型数值初始为1,这时信号量就是一个锁。
  • 同步信号量,信号量的整型数值初始为0,同步信号量类似于编程语言中的条件变量,它可以实现线程间的同步操作。
  • 计数信号量。

二值信号量(锁)

二值信号量可以当做一个互斥锁来使用,我们给信号量赋一个初始值1,此时这个信号量就是一个互斥锁。


import java.util.concurrent.Semaphore;

public class Sem {

    static class Number {

        int number = 0;

        Semaphore mutex = new Semaphore(1); /* 锁 */

        public void decrement() throws InterruptedException {

            mutex.acquire();

            number--;

            mutex.release();

        }

        public void increment() throws InterruptedException {

            mutex.acquire();

            number++;

            mutex.release();

        }

    }

    static final int LOOP_CNT = 10000;

    public static void main(String[] args) throws InterruptedException {

        final Number number = new Number();

        Thread decrement = new Thread(() -> {

            try {

                for (int i=0; i<LOOP_CNT; ++i) {

                    number.decrement();

                }

            } catch (InterruptedException e) {

                e.printStackTrace();

            }

        }, "decrement");

        Thread increment = new Thread(() -> {

            try {

                for (int i=0; i<LOOP_CNT; ++i) {

                    number.increment();

                }

            } catch (InterruptedException e) {

                e.printStackTrace();

            }

        }, "increment");

        increment.start();

        decrement.start();

        increment.join();

        decrement.join();

        System.out.println("number is " + number.number);

    }

}

这样的二值信号量模拟锁的逻辑也很好理解。当一个线程执行信号量的P操作时(在Java中信号量的P操作为acquire,信号量的V操作为release)。它会先将信号量中的整型数字作减一操作,因为信号量初值为一,所以第一个对信号量做P操作的线程不会被阻塞,进而进入临界区执行临界区代码。当第二个线程对信号量做P操作时,它会发现,此时整型数字已经小于一了,所以它会阻塞住无法执行临界区代码,直到先于它的线程对信号量作了V操作,这个阻塞的线程才有可能被唤醒进而进入临界区执行。

条件变量(Condition Variable)

当我们的信号量初始值为零时,此时信号量将作为一个条件变量来使用。例如下面的例子:


public class SemCondition {

    /* 构造一个Buffer,这个Buffer的最大容量为MAX_SIZE,如果超出了这个容量,就会自动清零 */

    static class Buffer {

        static final int MAX_SIZE = 500; /* 最大容量 */

        ArrayList<Integer> buffer = new ArrayList<>(); /* Buffer */

        /* 互斥锁,因为我们的ArrayList是一个线程不安全的类,所以对他进行操作时,要加锁 */

        Semaphore mutex = new Semaphore(1);

        /* 条件变量,当前的Buffer是否达到最大容量 */

        Semaphore isFull = new Semaphore(0);

        /* 条件变量,当前的Buffer是否已被清空 */

        Semaphore isEmpty = new Semaphore(0);

        void incBuffer() throws InterruptedException{

            mutex.acquire();

            buffer.add(0);

            if (buffer.size() > MAX_SIZE) {

                isFull.release();

                mutex.release(); /* 一定要在条件变量acquire之前释放掉互斥锁,不然就会死锁 */

                isEmpty.acquire();

            } else  {

                System.out.println(Thread.currentThread().getName() + " => 添加了一个元素");

                mutex.release();

            }

        }

        void cleanBuffer() throws InterruptedException{

            mutex.acquire();

            while (buffer.size() <= MAX_SIZE) {

                mutex.release();

                isFull.acquire();

            }

            System.out.println("缓冲区满了,正在清理....");

            Thread.sleep(2000);

            buffer.clear();

            isEmpty.release();

            mutex.release();

        }

    }

    public static void main(String[] args) {

        final Buffer buffer = new Buffer();

        for (int i=0; i<3; ++i) {

            new Thread(()->{

                while (true) {

                    try {

                        buffer.incBuffer();

                    } catch (InterruptedException e) {

                        e.printStackTrace();

                        break;

                    }

                }

            }, "inc_" + i).start();

        }

        new Thread(()->{

            while (true) {

                try {

                    buffer.cleanBuffer();

                } catch (InterruptedException e) {

                    e.printStackTrace();

                    break;

                }

            }

        }, "clean").start();

    }

}

操作系统并发的一些知识点梳理

原文:https://www.cnblogs.com/Huobn/p/14405447.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!