操作系统 —— 线程的概念以及控制

文章目录

    • 1. 线程的概念
      • 1.1 复习进程+理解线程 (Linux)
      • 1.3 对比window系统中线程实现
      • 1.4 线程的优缺点
        • 1.4.1 优点
        • 1.4.2 缺点
      • 1.5 线程的异常
    • 2. 线程实现原理
      • 2.1 线程共享资源与私有资源
      • 2.2 进程和线程的关系
      • 2.3 POSIX线程库
    • 3. 线程的控制
      • 3.1 线程的创建
      • 3.2 线程的等待
      • 3.3 线程的终止
      • 3.4 线程分离
    • 4. 线程互斥的实现 —— 锁
      • 4.1 线程互斥相关的几个概念
      • 4.2 线程互斥锁 —— 互斥量
        • 4.2.1 抢票程序
        • 4.2.2 对抢票程序加锁
      • 4.3 原子性的原理
      • 4.4 线程互斥锁的原理
        • 4.4.1 死锁
    • 5. 线程同步的实现 —— 条件变量
      • 5.1 条件变量 函数接口学习
      • 5.2 利用条件变量实现同步
      • 5.3 关于pthread_cond_wait的细节
    • 6. 结尾总结:

前言: 线程是什么?它和进程有什么区别?不同的操作系统下,线程的实现有什么不同?我们该如何控制线程?这是本章要解决的问题。


1. 线程的概念

程序的一个执行流就是线程,准确的说:线程是在进程内部运行的一个执行流,属于进程的一部分。每个进程都至少有一个执行流,称为主线程,在进程中可以有多个执行流,也就是有多个线程。

1.1 复习进程+理解线程 (Linux)

进程:程序代码 + 相关数据集 。程序被触发后,将程序的代码与所需数据加载到内存中,这个过程就是进程。进程是承担分配系统资源的基本实体,也就是说创建一个进程,时间空间的消耗是较大的,需要有物理内存,再通过页表,将虚拟内存和物理内存进行映射,还需要有进程控制块,来控制进程。那么我有个疑问?如果我就想简单的运行一个执行流,每次都得大费周章的创建进程,效率是不是有些慢。那么其实就有了线程的出现。

先简单画一下进程:

操作系统 —— 线程的概念以及控制_第1张图片

这是我们常学到的进程,由一个进程控制块来管理进程。上面概念里说过,线程是在进程中运行一个执行流,这该怎么理解?线程需要被管理起来吗?当然需要,由谁管理?也是由进程控制块管理的。一个进程控制块能管理多个线程?不可以。一个进程块可以管理一个进程?那么进程中多线程是如何实现的?

上面的整体是进程的一种,而且是比较特殊的进程,它只有一个执行流(只包含主线程)。有多个线程的进程是什么样的?如图:

操作系统 —— 线程的概念以及控制_第2张图片

可以由多个进程控制块来指向同一个进程地址空间。上图也是一个进程,不过它有多个线程,所以有多个进程控制块来管理线程。一个进程控制块就对应一个执行流。对,就是这样,Linux系统就是这样来实现线程的,感觉怪怪的,不过是真的好用,对比一下window系统的线程实现,就明白这样实现的好处了。

1.3 对比window系统中线程实现

管理线程,先描述在组织。那么管理线程必须要有线程控制块。没听错,是单独为了管理线程而创的结构体,这样做的成本非常高。

因为这样实现线程,会导致操作系统,还得单一识别一下是进程控制块还是线程控制块,管理进程和管理线程用的是两套方案,这里的线程可以看一个轻量化的进程,无疑这样会给cpu带来负担。

Linux实现线程,还是用的进程控制块管理的线程,进程去向操作系统要空间,然后线程作为进程中的一个一个的执行流,如果不额外创建线程,默认情况下进程只有一个主线程,也就一个执行流。

为什么Linux的线程实现使得cpu负担减轻了呢?cpu只用识别进程控制块,不用做区分。所以线程是cpu调度的基本单位,承担进程资源的一部分

1.4 线程的优缺点

1.4.1 优点

  1. 创建一个新线程的代价要比创建一个新进程小得多
  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
  3. 线程占用的资源要比进程少很多
  4. 能充分利用多处理器的可并行数量
  5. 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
  6. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
  7. I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

1.4.2 缺点

  1. 性能损失
    一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型
    线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的
    同步和调度开销,而可用的资源不变。
  2. 健壮性降低
    编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了
    不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
  3. 缺乏访问控制
    进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  4. 编程难度提高
    编写与调试一个多线程程序比单线程程序困难得多

1.5 线程的异常

可以想一下:多个线程共用一个进程的虚拟地址空间,其中的一个线程出现异常,会不会影响到其它的线程。

答案是:会影响,一个线程如果出现异常退出,它会导致进程异常退出,进程都退出了,其余的线程必然也都异常退出。

这个我们待会会验证。


2. 线程实现原理

2.1 线程共享资源与私有资源

进程具有独立性是好理解的,不同的进程有不同的虚拟地址空间(父子进程有点特殊),不同的页表映射,所以每个进程间是独立的,如果想要进程间通信,需要开辟一个共享内存,来完成通信。

线程的大部分资源都是共享的,同一个进程内多个线程,共用一个虚拟地址空间,也就是说这些线程都能够调用虚拟地址空间的代码区里的函数,还能使用同一个全局变量,这是好理解的,做一下总结:

  • 共享资源:
  1. 代码区(可执行代码,只读常量)
  2. 数据区(全局数据,静态数据)
  3. 文件描述符表(进程打开的文件,所有线程都能操作)
  4. 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
  5. 当前工作目录
  6. 用户id和组id

线程也是有私有资源的,人家也是有隐私的。比如:产生的临时数据,线程自身的属性等。虚拟地址空间是有栈区的它是用于存储临时变量的,所有线程都用一个栈?答案:当然不可以这样做。这个栈只给主线程使用。那么其余线程的临时数据存在哪里?存在共享库中的私有栈中,后面会画图讲解这里。私有资源,做一个总结:

  • 私有资源:
  1. 线程ID
  2. 一组寄存器
  3. 私有栈
  4. errno
  5. 信号屏蔽字
  6. 调度优先级

2.2 进程和线程的关系

总结有五种关系:

  1. 单进程单线程
  2. 单进程多线程
  3. 多进程 每个进程是单线程
  4. 多进程 每个进程是多线程
  5. 多进程 进程中有单线程和多线程

2.3 POSIX线程库

用户想要创建线程的成本是很高的,因为Linux系统并没有直接提供相关的接口,所以有大佬们为了降低使用成本,做了一个第三方库,给用户去调用,从而能够创建线程,使用线程。

这是一个动态库,是运行时进行链接的。有了这个理解后,我们回到上面,解决一个问题:线程的临时变量在共享库中如何存储?

画图:

操作系统 —— 线程的概念以及控制_第3张图片

共享库映射区在虚拟地址空间中,那么线程的临时数据存的物理地址在哪?在共享库中。

首先,线程库在磁盘上,使用它所以要加载到内存中。

操作系统 —— 线程的概念以及控制_第4张图片

其次,线程要存临时数据,就要通过页表和线程库构成映射

操作系统 —— 线程的概念以及控制_第5张图片

就是这样的,而且共享库映射区,是有一个虚拟地址空间地址 ,通过这个虚拟地址空间,以及在页表中的映射关系,就可以找到线程库中存的临时数据。

最后,简易理解一下,在线程库中线程私有资源的存储:

举个例子,线程库中有一个结构体数组 tcb[1000],这就表示可以存1000个线程私有资源块。

描述用户级的线程控制块(私有资源块):

操作系统 —— 线程的概念以及控制_第6张图片
第一个结构体,存的是线程的属性。

简单来说,线程的私有资源由用户级线程控制块保存,保存在线程库中的线程控制块数组中。


3. 线程的控制

得用第三方库 POSIX线程库。所以使用时必须满足以下条件:

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
  • 要使用这些函数库,要通过引入头文
  • 链接这些线程函数库时要使用编译器命令的“-lpthread”选项

3.1 线程的创建

操作系统 —— 线程的概念以及控制_第7张图片

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg)

函数的参数:

  • 第一个参数 thread ,这是一个输出型参数,返回的是线程的 ID
  • 第二参数 attr ,设置创建的线程的属性,给null的话是默认属性
  • 第三个参数 start_routine ,函数地址,线程要执行的函数
  • 第四个参数 arg ,传给start_routine的参数

函数的返回值:
成功返回 0 ,失败返回 错误码。

赶快验证一波:我们来创建线程

#include 
#include 
#include 
#include 

void *thread_run(void* args)
{
    while(1)
    {
        printf("I am new thread\n");
        sleep(2);
    }
}


int main()
{

    pthread_t tid;
    pthread_create(&tid, NULL, thread_run, "new thread");


    while(1){
        printf("I am main thread\n");
        sleep(1);
    }
}

看一下运行结果:

操作系统 —— 线程的概念以及控制_第8张图片
很明显是俩个执行流,交互打印。


操作系统 —— 线程的概念以及控制_第9张图片
pthread_t pthread_self(void);

此函数用于返回线程的ID。

可以用上面的程序,简单验证一下,还用上面的程序,稍改就行:

#include 
#include 
#include 
#include 

void *thread_run(void* args)
{
    while(1)
    {
        printf("I am new thread,my id is:%d\n",pthread_self());
        sleep(2);
    }
}


int main()
{

    pthread_t tid;
    pthread_create(&tid, NULL, thread_run, "new thread");


    while(1){
        printf("I am main thread,my id is:%d\n",pthread_self());
        sleep(1);
    }
}

操作系统 —— 线程的概念以及控制_第10张图片
发现线程 ID如上,这是用户级的线程ID,它本质就是虚拟地址空间中 的一个地址,操作系统操作线程,用的是内核级线程 ID -> LWP。

可以用指令 ps -aL 查看:

操作系统 —— 线程的概念以及控制_第11张图片
可以看到,PID是用于标识进程的,主线程的LWP和进程的PID是相同的,这也就解释了为什么,默认进程是有单一主线程的。可以看到,这两个线程的进程是同一个:PID都相同,所以是同一个进程下的两个线程。


3.2 线程的等待

一般来说,线程也是需要等待的,如果不等待,可能会有类似“僵尸进程”的情况。

比如:已经退出的线程,没有被回收资源,那么它的资源不会被释放。

操作系统 —— 线程的概念以及控制_第12张图片
int pthread_join(pthread_t thread , void ** retval);

  • 函数的参数:第一个参数 thread ,这就是线程的ID(用户级);第二参数 retval ,是输出型参数,用于保存线程的退出信息
  • 函数的返回值: 等待成功返回 0,失败返回 错误码。

这里比较难理解的就是第二参数是一个二级指针,因为线程的函数退出类型是 void*,想要拿到线程的退出信息,得是一个 void**,这样讲大家应该 get到了。

拿到的退出信息有:

  1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数
    PTHREAD_ CANCELED。
  3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参
    数。
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

可以做一个简单的验证:

#include
#include
#include

void* pthread_run(void* arv)
{
  printf("i am new prhread\n");
  
  sleep(2);
  return (void*)111;
}

int main()
{
  pthread_t id;

  pthread_create(&id,NULL,pthread_run,"new");
  
  sleep(1);
  printf("wait begin\n");
  
  void* status =NULL;
  int ret = pthread_join(id,&status);

  printf("ret :%d,退出信息:%d\n",ret,(int)status);

}

简单说一下:我创建了一个线程,它的返回信息 我给成 111 ,返回类型是 void*,所以需要做强转。然后就是等待此线程,拿出退出信息。

我们来运行一下:

在这里插入图片描述


3.3 线程的终止

线程有几种终止方式呢?

  1. 线程执行函数中return(main函数return,主线程退出,进程退出)(线程函数return,当前线程退出)
  2. 通过调用 pthread_exit 终止自己
  3. 取消目标线程 pthread_cancle
  • 第一种线程终止:通过return ,我们上面验证过了。
  • 第二种是通过调用 pthread_exit()来终止线程,切记线程中不要调用exit(),这是进程终止。
  • 第三种是被别的线程 取消线程,调用 pthread_cancle。

操作系统 —— 线程的概念以及控制_第13张图片

  • 函数的参数: retval是设置线程的退出信息,一般给null就可以
  • 函数的返回类型: void 不关心返回值
#include
#include
#include

void* pthread_run(void* arv)
{
  printf("i am new prhread\n");
  
  sleep(2);
  //return (void*)111;
  
  pthread_exit((void*)222);
}

int main()
{
  pthread_t id;

  pthread_create(&id,NULL,pthread_run,"new");
  
  sleep(1);
  printf("wait begin\n");
  
  void* status =NULL;
  int ret = pthread_join(id,&status);

  printf("ret :%d,退出信息:%d\n",ret,(int)status);

}

来看看运行结果:

操作系统 —— 线程的概念以及控制_第14张图片


操作系统 —— 线程的概念以及控制_第15张图片

  • 函数参数:thread 是线程的ID
  • 函数返回值:成功返回0;失败返回错误码

这里还是可以验证一下的:

#include
#include
#include

void* pthread_run(void* arv)
{
  printf("i am new prhread\n");
  
  sleep(20);
  //return (void*)111;
  //pthread_exit((void*)222);
  
  return 0;
}

int main()
{
  pthread_t id;

  pthread_create(&id,NULL,pthread_run,"new");
  

  sleep(1);
  printf("cancle begin\n");
  
  pthread_cancel(id);
  
  void* status =NULL;
  int ret = pthread_join(id,&status);

  if(status == PTHREAD_CANCELED)
  {
    printf("设置好了退出信息\n");
  }
}

这个程序,也能验证上面 进程等待退出信息:被其他线程cancle掉后,会设置退出信息为PTHREAD_CANCELED

运行结果:

操作系统 —— 线程的概念以及控制_第16张图片


3.4 线程分离

有没有一种情况:我不想等待线程,这个线程,完成它的功能自己退出,并释放资源就好了,我不关心它的退出信息。那么就是线程分离。

操作系统 —— 线程的概念以及控制_第17张图片

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。

这是好理解的,相当于 线程不需要被等待,自己运行结束就释放资源。


4. 线程互斥的实现 —— 锁

4.1 线程互斥相关的几个概念

  1. 临界资源:多线程执行流共享的资源就叫做临界资源
  2. 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  3. 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  4. 同步:在保证线程临界资源安全的前提下,使得线程访问临界资源,有一定的顺序性
  5. 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

4.2 线程互斥锁 —— 互斥量

如果是多线程去执行同一个函数,那么此函数就被重入了,函数中的比如全局变量,它的原子性就无法保持,所以需要加锁来对此函数的临界区进行保护。这有点不好理解,我可以用一个代码来验证上面的问题,然后利用锁来解决问题。

4.2.1 抢票程序

#include
#include
#include
#include
using namespace std;

class ticket
{
 private:
   int _tickets;
 public:
  ticket(int n=1000):_tickets(n)
  {
    ;
  }
  ~ticket()
  {
    ;
  }
  int get_ticket()
  {
    int res = 1;
    
    if(_tickets > 0)
    {
      usleep(1000);
      cout<<"I am" <<pthread_self()<< "抢走了票:"<< _tickets <<endl;
      _tickets--;
    }

    else 
    {
       res = 0;
       cout<<"票已经被强空了"<<endl;
     
    }

    return res;
}

};


void* Buy_tickets(void * ars)
{
   ticket* T = (ticket*) ars; 
   
   while(true)
   {
     if(!T->get_ticket())
     {
       break;
     }
   }
}



int main()
{

  ticket* T =new ticket();
  pthread_t id[5];
  
  for(int i=0;i<5;i++)
  {
   pthread_create(id+i,NULL,Buy_tickets,(void*)T);
  }
  
  for(int i=0;i<5;i++)
  {
    pthread_join(id[i],NULL);
  }
  return 0;
}

可以看到,上面就是我写的抢票程序,共有 5 个线程,去完成抢票,当票为空的时候,不会再去抢票。

运行一下:
操作系统 —— 线程的概念以及控制_第18张图片
惊奇的发现:票没了,但是还在抢票,而且抢的票还是一个负数。是什么原因呢?

因为五个线程重入了一个函数,而且访问的是同一个类对象 ticket T。都访问了类中的get_ticket(),这个函数中的临界区,没有加锁,所以导致 线程不安全,原子性丢失。

这样分析肯定是有点难理解,不过耐心点,下面会讲清楚的。


4.2.2 对抢票程序加锁

首先 我们来学习 互斥量 锁 mutex。

pthread_mutex_t 是锁的类型,本质也是一个变量。

有三种 创建锁的方式:

(1) 需要对锁 进行初始化(原生线程库,系统级别)

操作系统 —— 线程的概念以及控制_第19张图片

  • 创建锁:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

函数的参数 :mutex 就是我们创建的 pthread_mutex_t 类型的变量的地址,restrict mutex 就是 创建出锁的属性设置,默认给null就可以了。

函数的返回值:成功返回 0,失败返回错误码

  • 销毁锁:int pthread_mutex_destroy(pthread_mutex_t *mutex);

函数的参数:mutex 就是我们创建的 pthread_mutex_t 类型的变量的地址
函数的返回值:成功返回 0,失败返回错误码


操作系统 —— 线程的概念以及控制_第20张图片

  • 加锁 :int pthread_mutex_lock(pthread_mutex_t *mutex)

函数的参数:mutex 就是我们创建的 pthread_mutex_t 类型的变量的地址,前提是这个变量已经被初始化了。
函数的返回值:成功返回 0,失败返回错误码

  • 解锁:int pthread_mutex_unlock(pthread_mutex_t *mutex)

函数的参数:mutex 就是我们创建的 pthread_mutex_t 类型的变量的地址,前提是这个变量已经被初始化了。
函数的返回值:成功返回 0,失败返回错误码

(2) 不需要对锁进行初始化 (C++语言级别)

C++11,就对这个互斥量 mutex 封装成了一个类,更加的方便去调用。

操作系统 —— 线程的概念以及控制_第21张图片
我们不需要对它初始化,只要有了类对象,它会自动调用它的构造函数,也不用手动的释放锁,它会自动调用析构函数。只需要使用它的两个接口 lock()和 unlock() 就可以完成加锁和解锁。

(3) 静态的声明一个锁

static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

类似这样,声明一个静态的锁,也是可以的。这样就省去了初始化锁,和销毁锁。想要使用该锁还是需要调用接口:int pthread_mutex_lock(pthread_mutex_t *mutex)int pthread_mutex_unlock(pthread_mutex_t *mutex)

好,现在我们就对抢票程序,完成加锁:

#include
#include
#include
#include
using namespace std;

class ticket
{
 private:
   int _tickets;
   pthread_mutex_t mtx;

 public:
  ticket(int n=1000):_tickets(n)
  {
    pthread_mutex_init(&mtx,NULL);
  }

  ~ticket()
  {
    pthread_mutex_destroy(&mtx);
  }


  int get_ticket()
  {
    //static pthread_mutex_t mytx = PTHREAD_MUTEX_INITIALIZER;
    int res = 1;
    //pthread_mutex_lock(&mytx); 
    pthread_mutex_lock(&mtx); 
    
    if(_tickets > 0)
    {
      usleep(1000);
      cout<<"I am" <<pthread_self()<< "抢走了票:"<< _tickets <<endl;
      _tickets--;
    }

    else 
    {
       res = 0;
       cout<<"票已经被强空了"<<endl;
     
    }
     //pthread_mutex_unlock(&mytx);
     pthread_mutex_unlock(&mtx);
    return res;
}

};


void* Buy_tickets(void * ars)
{
   ticket* T = (ticket*) ars; 
   
   while(true)
   {
     if(!T->get_ticket())
     {
       break;
     }
   }

}



int main()
{

  ticket* T =new ticket();
  pthread_t id[5];
  
  for(int i=0;i<5;i++)
  {
   pthread_create(id+i,NULL,Buy_tickets,(void*)T);
  }
  
  for(int i=0;i<5;i++)
  {
    pthread_join(id[i],NULL);
  }
  return 0;
}

运行结果:

操作系统 —— 线程的概念以及控制_第22张图片

很明显,现在已经运行成功,但是还有一个问题,那就是:总是一个线程在抢票,其他线程竞争不过它,怎么让其它的线程也能抢上票呢?那就是同步。

4.3 原子性的原理

原子性,以前我们都是感性的理解,现在我来讲讲它的原理。

原子性:只要代码的本质是一条汇编语言,那么它就是原子性的。

提个问题:为什么上面的抢票程序,不加锁,就会出现问题?临界区的哪行代码破坏了原子性,那就是
_tackets- -;这不就是一句代码吗?错了,在汇编层其实是 三行代码。

验证:

int main()
{
	int a = 0;
	a--;
	return 0;
}

操作系统 —— 线程的概念以及控制_第23张图片

看到了 汇编是三行,先是将 [a]的值保存到 寄存器 eax,然后 对寄存器中的eax进行 - 1,最后再将 寄存器中的值保存到 [a]中。

它是先保存到寄存器中,然后进行的 -1 操作,那么我来解释一下,为什么上面不加锁会出现问题:

假如:

现在有俩个线程 A和B ,它俩去抢票, 总共有 1000张 ,比如先是 A 去抢票:

操作系统 —— 线程的概念以及控制_第24张图片

现在 B线程来了,它的竞争力非常强,A线程还没返回,B线程说:你把寄存器中内容先保存到你的上下文中,现在我要开始强票了,A线程说:好的,大哥,我保存一下寄存器的临时数据,我溜了,一会再来。

结果呢:B线程一直抢票,把票抢到只剩下 10 张,

操作系统 —— 线程的概念以及控制_第25张图片

此时:B线程,抢不动了,所以A线程开始运行,但是有个大问题,A线程保存的寄存器临时数据是999,它再次运行会把寄存器的数据存到内存中,这就导致票数 回到了 999 ,也就说 B线程白干了。

操作系统 —— 线程的概念以及控制_第26张图片
这就破坏了原子性,所以一个线程做一件事要做完嘛,做的半路被打断,容易出问题。


4.4 线程互斥锁的原理

怎么才能保护临界区临界资源的原子性呢?可以使用互斥量 mutex ,它本质就是一个变量,默认情况下,它的值是 1。 如果有线程申请锁,那么 mutex的值置为 0;下一个线程来申请锁,发现 mutex的值为 0,那么线程挂起等待。就这样 保护一个线程运行临界区时的原子性。

那么我有点问题:锁也是临界资源,它的原子性谁保证?锁的申请 是用的一条汇编代码 xchgb 寄存器,mutex;
锁的释放 是用的一条汇编代码 movb 1,mutex。这用一条汇编代码,就是保证了锁的申请和释放是原子性的。

如下图:

假如有A,B线程来申请锁,A线程的寄存器 al被置为 0,mutex默认值为 1,所以 xchgb 交互一下 al 和 mutex的值,al寄存器现在的值 为 1,mutex的值为 0,表示 A线程申请锁成功,现在锁已经没有了;B线程来了,将寄存器al的值,置为0,注意 B线程来了,A线程中al中值会被覆盖,但是 A线程会自行保存al中的值,下次 A线程来了会恢复上下文数据的。但是mutex的值是内置变量,它现在还是 0表示锁被申请走了,所以 B线程被挂起等待。

如果 A线程释放锁,那么 mutex的值置为 1,再唤醒 被挂起的 B 线程就好了,然后 B线程就可以申请锁了。

操作系统 —— 线程的概念以及控制_第27张图片

我现在懂了,是这样完成 加锁,解锁的。但是 有没有可能 线程A申请到锁了 ,开始执行临界区代码,突然被切到别的线程了? 是有可能的,但是 线程A是带着锁被切走的,它没释放锁,现在执行的线程可以申请到锁吗?

当然不能,线程A 并没有释放锁,所以 线程A对临界区的原子性保护依旧存在,其他的线程 都无法申请到锁,只能乖乖的切会到 线程A,等人家释放了锁,再去 重新申请锁。当然这里不能混淆,锁是可以有多个的,但是一个临界区由一个锁维护就够了。

上面的加锁线程 A,它就是 一直抱着锁 不释放,所以一直都是它来运行,这就造成了其他线程饥饿问题,如何才能够解决呢?同步

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

4.4.1 死锁

所以 有了以上理解,现在来谈谈一种特殊的锁 : 死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资
源而处于的一种永久等待状态。

怎么理解呢?我举个例子:

主线程 去调用一个函数 insert(),这个函数是临界资源;自定义函数 hander()是对2号信号的捕捉,那么这就可能产生死锁:

操作系统 —— 线程的概念以及控制_第28张图片

main()函数调用 insert(),申请到锁,但是 中途收 2号信号,去执行 handler函数,又去调用 insert(),那么又去申请锁。操作系统懵了,我给你这个线程一把锁,但是 同样是你 咋又来申请锁了?那么这个线程 就一直被挂起了。

也就是说 :一个线程 重复申请同一个锁,就会导致 死锁。


5. 线程同步的实现 —— 条件变量

什么时候需要有同步呢?

在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。

同步的实现用的是条件变量,互斥实现用的是互斥量,所以条件变量也没那么神奇,不过就是让线程满足 条件变量时,做相应的操作罢了。

5.1 条件变量 函数接口学习

操作系统 —— 线程的概念以及控制_第29张图片

初始化条件变量:int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrictattr);

函数参数 :第一个参数是要初始化的条件变量的地址;第二个参数是创建条件变量的属性,默认给null就行了
函数返回类型:成功返回0,失败返回错误码

销毁条件变量:int pthread_cond_destroy(pthread_cond_t *cond);

函数参数:要销毁的条件变量的地址
函数返回类型:成功返回0,失败返回错误码


操作系统 —— 线程的概念以及控制_第30张图片
使得线程在某个条件变量下等待:
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

函数参数:第一个参数表示在那个条件变量下等待;第二个参数是互斥量,等待前所待的锁
函数返回值:成功返回 0,失败返回 错误码


线程的等待,需要被唤醒,它可不能死等:

操作系统 —— 线程的概念以及控制_第31张图片
唤醒一个在这个条件变量下等待的线程:

int pthread_cond_signal(pthread_cond_t *cond);

函数的参数:条件变量的地址
函数的返回值:成功返回 0,失败返回错误码

唤醒所以在这个条件变量下等待的线程:

int pthread_cond_broadcast(pthread_cond_t *cond);

函数的参数:条件变量的地址
函数的返回值:成功返回 0,失败返回错误码

5.2 利用条件变量实现同步

举个例子:一个老板boss,手底下有 5个员工Employee。我要求 boss线程,控制 这个 5个Employee线程:

一步一步来完成,方便理解互斥和同步:

#include
#include
#include
#include
using namespace std;

void* work(void* j)
{
    int n = *(int*)j;
    while(true)
    {
     cout<<"i am "<<n<<"employee"<<"working"<<endl;
     sleep(1);
    }
}
int main()
{
  pthread_t id[5];

  for(int i=0;i<5;i++)
  {
     void* j =(void*)&i; 
     pthread_create(id+i,NULL,work,j);    
  }
  
  for(int i=0;i<5;i++)
  {
    pthread_join(id[i],NULL);
  }
  return 0;
}

这就简单的创建了 5个线程,都去执行work 函数,但是 work函数是里的代码基本都是临界资源,所以加上锁才是比较好的决策:

#include
#include
#include
#include
using namespace std;

pthread_mutex_t mtx;

void* work(void* j)
{   
    pthread_mutex_lock(&mtx);
    int n = *(int*)j;
    while(true)
    {
     cout<<"i am "<<n<<"employee"<<"working"<<endl;
     sleep(1);
    }
    pthread_mutex_lock(&mtx);
}

int main()
{
  pthread_t id[5];
  
  pthread_mutex_init(&mtx,NULL);

  for(int i=0;i<5;i++)
  {
     void* j =(void*)&i; 
     pthread_create(id+i,NULL,work,j);    
  }
  
  for(int i=0;i<5;i++)
  {
    pthread_join(id[i],NULL);
  }
 
  pthread_mutex_destroy(&mtx);
  return 0;
}

我们看一下运行结果:

操作系统 —— 线程的概念以及控制_第32张图片

发现一直是 第5号员工,在工作,非常不银杏,所以决定使用同步,来分配一下,解决一下 其他线程的饥饿问题,需要有一个老板来进行管理:

#include
#include
#include
#include
using namespace std;

pthread_mutex_t mtx;
pthread_cond_t con;

void* ctrl(void* j)
{
  string name =(char*)j;
  while(true)
  {
   sleep(1);
   cout<<"i am"<<name<<"please work"<<endl;
  pthread_cond_signal(&con); 
  // pthread_cond_broadcast(&con);
  }
}

void* work(void* j)
{   
    pthread_mutex_lock(&mtx);

    int n = *(int*)j;
    delete (int*)j;

    while(true)
    {
     pthread_cond_wait(&con,&mtx);
     cout<<"i am "<<n<<"employee"<<"working"<<endl; 
    }

    pthread_mutex_unlock(&mtx);
}

int main()
{
  pthread_t id[5];
  
  pthread_mutex_init(&mtx,NULL);
  pthread_cond_init(&con,NULL);
  
  pthread_t boos;
  pthread_create(&boos,NULL,ctrl,(void*)"boss");

  for(int i=0;i<5;i++)
  {
     int * num= new int(i); 
     pthread_create(id+i,NULL,work,(void*)num);    
  }
  
  for(int i=0;i<5;i++)
  {
    pthread_join(id[i],NULL);
  }
  
  pthread_cond_destroy(&con);
  pthread_mutex_destroy(&mtx);

  return 0;
}

我们看效果:

操作系统 —— 线程的概念以及控制_第33张图片
很明显,已经完成同步。


5.3 关于pthread_cond_wait的细节

它的第二个参数 是 锁的地址。感觉有点奇怪:

建议:保证 pthread_cond_wait的原子性,因为它要等待被唤醒,这个过程不应该被打断,因为被解锁后,可能会错过被唤醒,得上锁,意思就是在lock和unlock之间进行等待,但是啊,它等待的过程中,是会自动解锁的,这真的有点绕。

如果wait时它依旧一直上锁,别的线程想要访问临界资源,无法和它竞争锁,会一直等待,这不太好,所以

pthread_cond_wait偷偷的做了两件事:

  1. 调用的时候,会首先自动释放mtx_!,然后再挂起自己
  2. 返回的时候,会首先自动竞争锁,获取到锁之后,才能返回!

关于这个,在我后续的博客< 生产者,消费者>里,还会说的。如果这里get不到,那就满满去体会,这话说的有的像个渣男。


6. 结尾总结:

以上就是线程的内容,包括线程的概念理解,线程的控制,以及线程互斥和线程同步的实现。有问题的朋友,可以私信或评论,感觉有帮助的朋友可以给个小赞,支持一下。

你可能感兴趣的:(操作系统,linux,运维,c++,服务器)