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

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

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

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

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

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

有关进程

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

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

因此进程与程序的关系大体以下:网络

  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();

    }

}
相关文章
相关标签/搜索