-梅头像
linux(12) 线程封面图

linux(12) 线程

前言:

首先呢,线程将会是我们linux系统中的最后一个章节,在这篇文章之后,我们还要写一个线程池,那么我们的linux系统就结束了;

在那之后,会开始写计算机网络相关的内容,当然,也会写一些c++,或者leetcode刷题的文章,以前倾向于做笔记,现在开始用博客记录,感觉效果也不错(其实是写字太丑自己也不认识)

那么废话少说,我们接下来就进入今天的正题.线程我打算怎么讲?

  1. 要将线程,首先我得给大家先简单介绍一下线程,但是呢,直接上来谈线程,会有些逻辑不太清楚,所以在此之前,我们要对虚拟地址来最后的一次加深理解,我们这一次,会来介绍物理地址,以及页表;
  2. 之后呢,便是讲讲线程的概念,优缺点,以及线程之间什么是共享的,什么是独立的,还有其与进程的关系,以及在不同操作系统下的区别
  3. 在此之后,我们就来正式理解线程的使用了,正如同我们讲解进程一样,我们也是要讲线程的创建,终止,等待......
  4. 当然,在我们讲3的时候,会遇到一个问题,“id”,无需担心,我们自然会在下面进行讲解
  5. 之后,我们要讲线程与线程之间的协同工作了,其中绕不开的就是:互斥,同步;所以,我们要讲解一下锁和条件变量
  6. 再然后,我们会聊一聊线程安全的话题(当然与函数重入一起讲解)
  7. 最后我们来聊聊死锁这个话题
  8. 这时候有人就要问了,生产者消费者模型不讲么?我们留在下一篇文章,来实现线程池的时候,我们在进行详细介绍

好,那么以上就是我们今天的任务了,感觉压力巨大呢.....

正文:

地址空间:

虚拟地址的由来:

首先我们已经讲过三次虚拟地址空间了,那么我们为什么需要用虚拟地址,通过页表来映射物理内存呢,虚拟地址又是怎么编写的,和物理内存的关系是什么呢,我们以前并没有提及过,不过今天我们就要一个个来解决这些问题了。

我们首先来看,我们为什么要使用虚拟地址?

答:在我们的物理内存之中,必然会有很多的进程,而每一个进程,它的大小必然都是不一样的,而每隔一会,有的进程要创建,有的进程要退出,所以不可避免的,进程与进程之间,就会存在很多的“碎片”;而我们作为用户,我希望能方便的管理我们的进程,所以我希望进程这一块地址,它是连续的,而站在物理内存角度,我又希望这一片进程它不连续,这样我就可以把我的碎片地址给它,这样可以实现最大程度的利用;

但显然,面对这种要求,只有物理内存,是不现实的

所以这时候,我们就提出了虚拟地址,我们在进程角度使用虚拟地址,看到的就是一片连续的空间,同时通过页表映射到物理地址,我在物理内存角度,就可以把那些碎片地址和它建立映射,从而实现我们上述的目的,这就是虚拟地址的由来;

物理内存是怎么样的?

 我们的物理内存,是以一个个页框进行分割的,而页框的大小,32位下为4kb,64位下为8kb,我们这里就按照32位讲咯

我们32位平台下,物理内存为4GB,我们把它除以4KB,可以得到1024*1024,那么这个数字,就是我们的物理内存中,会有多少个页框(1048576)

那么这个时候,有的物理内存正在被使用,有的物理内存正在被释放,有的物理内存正在被申请......这么多状态,我们是不是应该对物理内存进行管理?当然要,怎么管理?

先描述再组织

描述:

我们用struct page来表示物理内存中的每一个内存页,当然,它的成员很多,我们只是列出了一部分,但是别看成员多,其中大部分使用了联合体(union),所以它的实际大小并不会太大

struct page {
    unsigned long flags;        // 【重点】页状态标志位:PG_locked(页锁)、PG_dirty(脏页)、PG_reserved等
    union {
        struct list_head lru;   // 【重点】LRU链表节点,页面回收用(冷热页,伙伴系统/kswapd)
        struct page *next;
    };
    struct address_space *mapping; //【重点】归属:属于哪个页缓存(文件映射);NULL=匿名页
    pgoff_t index;              //【重点】在address_space里的偏移(文件内页偏移)
    atomic_t _refcount;         //【重点】引用计数!多少地方正在引用这个物理页,到0才可以释放
    unsigned int mapcount;      //【重点】页表映射计数:多少个进程页表映射了这个物理页
    void *private;              // 私有指针:块IO、buffer_head、slab等附属数据
};

我们假设它有50字节的大小,我们内存里有1024*1024个它,那么我们的page结构体加起来也才50MB,并不算太大,所以我们是可以接受的;

页表:

我们在前几次讲解虚拟地址的时候,我们对页表的印象还停留于,虚拟地址通过页表映射物理地址,那么这个过程是如何实现的呢?我们就要来看一下页表的结构了。

我们首先要知道,内核里面创建页表,是通过一个数组的,而数组又是一段下标连续的空间,而我们要映射1024*1024个页框,我们就要有1024*1024个页表项,假设每一个四字节,那么最后即是4MB,也就是说,页表自己本身,就要占用1024个连续的内存块,这似乎与我们最开始的目的有点背道而驰了......

我们最开始引入页表,就是为了增加碎片化物理空间的使用率,但是现在,引入页表之后,它本身就需要大块的完整空间,并且同时,我们似乎并不需要使用所有页表吧?我们在一个进程中,往往只需要使用三四个页表就足够了,所以此时我们想到了什么办法?

正如同我们当年在讲文件系统的数据块和inode的时候,由于数据块实在是太多了,我们为了用有效的空间表示足够多的数据块,我们就引入了分级数据块,此时我们也可以按照这个思路,引入多级页表

长什么样:

此时,我们就引入了页目录,也就相当于页表一级索引了(理解上),它存的是页表的起始地址。同时,该地址也是物理地址,不是虚拟地址;

页目录中存放了1024个页表起始地址,而每个页表里又有1024个页表项,所以一共就可以表示1024*1024个内存块

低十二位:

而页表存的是物理页起始地址,也是物理地址,并且指向一整块4KB的空间,所以该地址必然是4096的整数倍,4096又是2^12,所以我们页表的低十二位,其实都是为0的......

同时,那页目录中存的是页表的起始地址,页表本身也是按4KB对齐存放的,所以页表起始地址也是4096的整数倍,所以页目录的第十二位,也都为0;

那么我们以前说过,页表中可是有权限位的,那么在哪里呢?没错,就是在这页目录和页表的低十二位放着

标志位页目录项 PDE(指向页表)页表项 PTE(指向物理页框)
P (第 0 位,Present)页表是否在内存。P=0:页表不在内存,触发缺页,地址字段无效物理页是否在内存。P=0:物理页不在内存,缺页异常
R/W (第 1 位,读写)控制整张页表下所有页的读写权限(可以被 PTE 的 R/W 细化约束)控制这单个物理页能不能写
U/S (第 2 位,用户 / 超级)整张页表:0 = 内核态才能访问;1 = 用户态可访问仅当前这一页:0 = 内核态;1 = 用户态
PWT (第 3 位)页表的缓存写通策略当前物理页的缓存写通策略
PCD (第 4 位)是否禁止缓存这张页表是否禁止缓存当前物理页
A (第 5 位,Accessed)CPU 访问这个页表时,硬件自动置 1(访问标记)CPU 访问这个物理页时,硬件自动置 1
D (第 6 位,Dirty)PDE没有 D 位!永远无效页写入时硬件自动置 1,用来判断要不要刷回磁盘
PAT (第 7 位)内存类型扩展位,配合 PWT、PCD内存类型扩展位
G (第 8 位,Global)PDE 无 G 位全局页,切换 CR3 的时候 TLB 不刷这个表项
9~11 位保留位,软件自用保留位,软件自用

这便是二者的对比,不过最重要的是:

  • PDE 管一整个页表区域,粒度大,没有脏位 D;
  • PTE 只管单独一页物理内存,粒度精细,有脏位 D,记录页有没有被修改
通过虚拟地址索引物理地址:

首先我们知道,一个虚拟地址4字节,有32位;

页目录一共1024个成员,2^10;

页表里一共1024个成员,2^10;

而在一个内存块中4096个字节,2^12;

所以虚拟地址,实际上是以上三者拼接而来,通过高10位,中10位,低12位的索引,我们就可以锁定物理内存中的任何一个字节

索引流程如上;

所以我们此时再想想,虚拟地址,是不是就映射着一块物理地址,那么是否就象征着资源,虚拟地址越多,是不是我所得到的资源就越多?

而且我们想想,这个查表的工作,它一定会很频繁的,因为我基本无时无刻都在访问一个地址,而这个工作,又非常机械化,格外的简单,这个工作交给谁来干呢?

没错,硬件电路来干就非常合适,所以查表的工作,我们会交给MMU;而上述我们的虚拟地址转物理地址的过程,就是MMU工作流程

以上,就是我们关于地址空间的最后一讲~给大家补上了完整的概念~

线程

概念:

进程是操作系统进行资源分配的基本单位;

线程是 CPU 调度的基本单位。

在这里先纠正一下以前的一个误区,我们以前讲的时候,CPU调度的不是进程么?

其实,我们在学线程以前,我们的进程,都可以看作是只有一个线程的进程!

咦?所以一个进程里面,会有多个线程么?

没错!

在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是“⼀个进程内部
的控制序列”

所以线程才是CPU调度的基本单位!

那么,第一句话如何理解呢?什么叫做进程是操作系统进行资源分配的基本单位?什么资源?谁来分走它的资源?

我们先画一张图:

问问大家,这张图里面哪些数据属于我们进程的资源?

答案是紫框部分,我的pcb,页表,还有整块的虚拟地址空间,都是我进程的资源

从图中我们也可以看出,在linux系统中,线程其实是一个pcb? 

没错就是这样,在linux系统之中,当时设计的时候,如果设计线程结构体tcb,那我是不是还要重写一整套配套的函数?这工作量有点大,所以我们的linux程序员选择直接复用pcb的代码,故其实在linux内核之中,是没有线程的概念的,我们把线程叫做轻量级进程。而在CPU眼中,不管是进程还是现场,统一都是轻量级进程

那么.....别的系统也这样么?

并不是,在Windows系统之中,它真的给线程设计了一个独立的结构体:

  • EPROCESS:进程,承载资源(地址空间、句柄表),一份
  • ETHREAD:线程,只承载调度上下文,依附在 EPROCESS 上;内核原生就把进程、线程分成两种不同对象。

所以在Windows系统之中,它是有线程的概念的;

所以我们最后总结一下:

Linux 内核里,线程本质就是一条独立执行流;它在内核上依然是 task_struct(PCB),只是它和同组其他 task_struct 共享 mm_struct、文件描述符表等进程资源。

优缺点:

优点:
  1. 切换开销小:同进程线程切换,不需要切换页表(不用换 CR3),只换寄存器、栈、TLS,比进程切换快很多。
  2. 通信简单:共享虚拟地址空间,全局变量、堆可以直接读写,不用进程间通信(管道 / 共享内存等)。
  3. 创建销毁快:clone时只新建 task_struct、线程栈,不用重新分配 mm_struct、页表这套资源。
  4. 充分利用多核 CPU:一个进程内多个线程可以并行跑在多个核心上,提升 CPU 密集型、IO 密集型程序效率。
缺点:
  1. 缺乏隔离,安全性差:一个线程崩溃(野指针、越界),整个进程直接挂掉;进程之间天然隔离。
  2. 共享资源带来并发问题:多线程同时访问共享数据,会产生竞态条件,需要锁、条件变量,编码难度上升,容易死锁。
  3. 资源抢占:同进程所有线程共享文件描述符、信号处理表等,信号、fd 在多线程场景行为会变复杂(信号发给整个进程)。
  4. 调试更麻烦:多线程时序不确定,bug 难以复现(海森堡 bug)。

共享与独立:

当然,我们线程之间,有共享的资源,也有独立的资源:

共享:
  1. 页表:CR3
  2. 虚拟地址空间
  3. 文件相关:文件描述符表,当前工作目录,umask
  4. 信号:三张表,信号是发给进程的
  5. pid
独立:
  1. 栈:每一个线程尤其独立的栈
  2. 一些寄存器资源
  3. 线程id(tid)
  4. 线程局部存储:__thread

线程与进程关系

进程是资源分配的容器,线程是跑在这个容器里面、共享容器资源的独立 CPU 执行流。

线程使用:

头文件:<pthread.h>,编译加 -lpthread

1. pthread_create 创建线程

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void*), void *arg);
  • 返回值:成功返回 0,失败返回错误号(不设置 errno)
  • 参数
    1. thread:输出型参数,拿到新线程 id
    2. attr:线程属性,NULL 用默认(可设置分离态、栈大小)
    3. start_routine:线程入口函数,签名固定:void* func(void*)
    4. arg:传给入口函数的参数,只能传一个
  • 细节:
    • 新线程和主线程共享地址空间,栈独立
    • 线程调度顺序不确定,创建完不一定立刻跑
    • 默认是可结合态(joinable),必须 join,否则资源泄漏

2. pthread_exit 线程终止

void pthread_exit(void *retval);
  • 作用:本线程主动退出,不会影响同进程其他线程
  • retval:退出返回值,后面 pthread_join 可以拿到
  • 细节重点:
    • return 在入口函数里等价 pthread_exit;主线程 return = 整个进程退出,所有线程全杀
    • 不要在线程里调用 exit(),exit 直接结束整个进程
    • retval 不能是线程栈上局部变量(线程退出栈回收,野指针)

注意,线程可以调用pthread_exit终止自己,同时也可以在线程的回调函数内部return,但是这种方法对主线程不适用,主线程return,那么我的整个进程都退了

3. pthread_join 等待线程

int pthread_join(pthread_t thread, void **retval);
  • 返回值:成功 0,失败错误码
  • 参数
    1. thread:要等待的线程 ID
    2. retval:输出,接收线程 exit 返回值,不需要传 NULL
  • 细节重点:
    • 阻塞等待目标线程结束;目标已经结束,join 直接返回
    • 只能等 joinable(非分离)线程,分离线程 pthread_join 会失败
    • 一次 join 只能回收一个线程;回收后该 tid 失效,不能重复 join
    • 用途:拿到返回值 + 回收线程内核资源(PCB)

4. 分离线程:pthread_detach(分离,不用 join)

int pthread_detach(pthread_t thread);
  • 把线程设为分离态,线程结束自动释放资源,不能再 join

代码示例:

好,我知道在这里我们会有一些疑惑,不过我们稍后解答,还是先写一段代码

#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
void* thread_run(void* argc)
{
    for(int i=0;i<5;i++)
    {
        sleep(1);
        printf("我是一个线程我的id是%lu\n",pthread_self());
        printf("%s\n",(char*)argc);
    }
    return (void*)1;
}
int main()
{
    pthread_t id;
    pthread_create(&id,nullptr,thread_run,(void*)"ruonawa");
    printf("%lu\n",id);
    sleep(7);
    void* ret;
    pthread_join(id,&ret);
    printf("%d\n",(int)ret);
    return 0;
    
}

测试结果:

wuchun@VM-0-10-ubuntu:~/10_28$ ps -aL
    PID     LWP TTY          TIME CMD
3115883 3115883 pts/0    00:00:00 ceshi
3115883 3115884 pts/0    00:00:00 ceshi
3115934 3115934 pts/1    00:00:00 ps

LWP=gettid()

tid是内核调度的基本单位

每个线程唯一

139259430303424
我是一个线程我的id是139259430303424
ruonawa
我是一个线程我的id是139259430303424
ruonawa
我是一个线程我的id是139259430303424
ruonawa
我是一个线程我的id是139259430303424
ruonawa
我是一个线程我的id是139259430303424
ruonawa
1

我们在这里会发现,线程id是一个很长的数字那这个数字是什么呢?

线程id

我们首先知道,在linux内核中,其实是没有线程的概念的,但是,内核中没有,不代表不能在库里面封装一个,所以我们的linux在原生线程库中,封装了线程;

struct thread 就是线程在库中的封装,而thread_t,则是指向struct thread 的指针:

在 glibc 的 NPTL 实现里: pthread_t = struct pthread *

就是指向这个用户态线程控制块的指针。

在这里区分一下:

  1. 内核识别:gettid (),内核 task_struct 里的线程 id,内核调度、信号、系统调用看这个。
  2. pthread 库、用户代码识别:pthread_self (),返回pthread_t(struct pthread*),库的接口(join、detach)认它
struct pthread
{
    // TCB 线程控制块头,TLS依赖
    tcbhead_t header;

    void *start_routine;   // 你的线程回调函数
    void *arg;             // 传给回调的参数
    void *result;          // 重点:线程返回值,pthread_join读取的就是它
    int tid;               // 内核tid(gettid()的值)

    void *stackaddr;       // 线程栈起始地址
    size_t stacksize;      // 栈大小
    void *stack_guard;     // 栈溢出保护页

    int cancelstate;
    int canceltype;
    int canceled;

    // 分离 / 可结合状态标记
    enum
    {
        PTHREAD_JOINABLE,
        PTHREAD_DETACHED
    } joinstate;

    // 清理回调(pthread_cleanup_push)
    struct pthread_cleanup_buffer *cleanup;

    dtv_t *dtv;            // TLS动态向量,__thread 靠这个
    list_t list;           // 库内线程链表节点
};

所以呢,我的线程id是什么?

答:是指向库中struct pthread的指针

同时,我们说过,每个线程拥有其自己的指针,那么在哪里?在库中,库中给其开辟好栈,然后将起始地址存到 struct pthread之中

同时,我们也解决了如何获取返回值的问题,我们调用pthread_join,从而通过传参传入的id找到了我们线程在库中的地址,从而访问struct pthread结构体,其中就存放着我们的返回值

话说,有没有感觉到有一些熟悉呢,这样的封装(tid与pthread)......似乎我们前几节课讲的文件系统fd与file,是同一种

线程互斥:

先把概念再给大家提一遍:

  1. 共享资源:没什么说的
  2. 临界资源:多线程执⾏流被保护的共享的资源就叫做临界资源
  3. 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  4. 互斥:任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起保护作⽤
  5. 原⼦性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

下面是 CPU 硬件层面实现原子操作的两大类方式:

(了解一下即可,我主要想说的是第一点)

①单条指令原子(x86 常用)

一条汇编指令,硬件保证整个执行过程不可被打断。

  • 自带原子:xchg(交换,自带隐式 lock)
  • lock前缀 + 普通指令:lock add / lock cmpxchg
  • 适用:x86 架构。
  • 特点:一条指令完成

②  LL/SC 指令对(Load-Linked / Store-Conditional) 两条配套指令配合实现原子,ARM、RISC-V 使用。

  • ll:读取变量,硬件标记监视该内存地址
  • sc:尝试写入;如果这段内存没有被别的 CPU 修改,写入成功,否则失败重试
  • 特点:不是单条指令,依靠硬件监视标记,失败需要循环重试

回归主题:

直接看现象:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *thread_run(void *argc)
{
    char *id = (char *)argc;
    while (1)
    {
        if (ticket > 0)
        {
            usleep(100);
            printf("%s get ticket: %d\n",(char*)argc,ticket);
            ticket--;
        }
        else{
            break;
        }
    }
}
int main()
{
    pthread_t id1,id2,id3,id4;
    pthread_create(&id1,nullptr,thread_run,(void*)"thread_1");
    pthread_create(&id2,nullptr,thread_run,(void*)"thread_2");
    pthread_create(&id3,nullptr,thread_run,(void*)"thread_3");
    pthread_create(&id4,nullptr,thread_run,(void*)"thread_4");
    sleep(100);
    return 0;
}

我们今天创建了四个进程,来模拟一个抢票的场景,那我们运行起来会怎么样呢?

thread_1 get ticket: 100
thread_4 get ticket: 99
thread_2 get ticket: 98
thread_3 get ticket: 97
thread_1 get ticket: 96
thread_4 get ticket: 95
thread_2 get ticket: 95
thread_3 get ticket: 95
thread_1 get ticket: 95
thread_2 get ticket: 91
thread_3 get ticket: 90
thread_1 get ticket: 89
thread_4 get ticket: 88
thread_2 get ticket: 87
thread_3 get ticket: 86
thread_1 get ticket: 85
thread_4 get ticket: 87
thread_4 get ticket: 83
thread_1 get ticket: 82
thread_3 get ticket: 81
thread_2 get ticket: 80
thread_3 get ticket: 79
thread_1 get ticket: 79
thread_2 get ticket: 79
thread_4 get ticket: 78
thread_3 get ticket: 75
thread_1 get ticket: 74
thread_2 get ticket: 74
thread_4 get ticket: 74
thread_2 get ticket: 71
thread_1 get ticket: 71
thread_4 get ticket: 71
thread_3 get ticket: 71
thread_3 get ticket: 67
thread_4 get ticket: 67
thread_2 get ticket: 67
thread_1 get ticket: 67
thread_3 get ticket: 63
thread_4 get ticket: 63
thread_1 get ticket: 63
thread_2 get ticket: 63
thread_2 get ticket: 59
thread_3 get ticket: 59
thread_4 get ticket: 59
thread_1 get ticket: 57
thread_3 get ticket: 55
thread_2 get ticket: 55
thread_4 get ticket: 55
thread_1 get ticket: 55
thread_3 get ticket: 51
thread_1 get ticket: 50
thread_2 get ticket: 49
thread_4 get ticket: 48
thread_3 get ticket: 47
thread_1 get ticket: 46
thread_2 get ticket: 45
thread_4 get ticket: 44
thread_3 get ticket: 43
thread_4 get ticket: 42
thread_1 get ticket: 41
thread_2 get ticket: 40
thread_3 get ticket: 39
thread_2 get ticket: 38
thread_4 get ticket: 37
thread_1 get ticket: 36
thread_3 get ticket: 35
thread_1 get ticket: 34
thread_2 get ticket: 33
thread_4 get ticket: 32
thread_3 get ticket: 31
thread_1 get ticket: 30
thread_4 get ticket: 29
thread_2 get ticket: 28
thread_2 get ticket: 27
thread_4 get ticket: 26
thread_1 get ticket: 25
thread_3 get ticket: 24
thread_1 get ticket: 23
thread_4 get ticket: 22
thread_3 get ticket: 21
thread_2 get ticket: 20
thread_3 get ticket: 19
thread_4 get ticket: 18
thread_2 get ticket: 17
thread_3 get ticket: 16
thread_1 get ticket: 15
thread_3 get ticket: 14
thread_1 get ticket: 13
thread_2 get ticket: 12
thread_4 get ticket: 11
thread_3 get ticket: 10
thread_1 get ticket: 9
thread_2 get ticket: 8
thread_4 get ticket: 7
thread_2 get ticket: 6
thread_3 get ticket: 5
thread_1 get ticket: 4
thread_4 get ticket: 3
thread_2 get ticket: 2
thread_3 get ticket: 1
thread_1 get ticket: 0
thread_4 get ticket: -1
thread_2 get ticket: -2

我们发现,居然还能抢到0,-1,-2?!

这显然不符合常理,也不符合我的代码,我明明设置了判断,只有当抢票时,票数大于0,我才可以进入循环,开始抢票的,为什么还会出现这个问题?

因为我们今天不是单线程了,我们是多线程!!!

就如这张图中所写

那么这时候就有人要说了,哎呀,那我是不是,只要在判断语句里面塞一个ticket--语句,是不是一下子就执行完了,就不会因为切换线程导致的并发问题?

我们的ticket--,它是一条语句对吧?

但我们的程序,它实际要执行的是二进制程序啊,二进制程序又是自汇编语言而来,我们来看看ticket--在汇编下长什么样子?

取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax #600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) #600b34 <ticket>

可惜了,原本一条ticket--的指令,在我们汇编代码下,变成了三条语句;

load :将共享变量ticket从内存加载到寄存器中
update : 更新寄存器⾥⾯的值,执⾏-1操作
store :将新值,从寄存器写回共享变量ticket的内存地址

所以此时,我就想想,我是一个线程,我在执行临界区的时候,我不想别人也进来,这就需要锁!

互斥锁pthread_mutex_t:

pthread_mutex_t mutex;

定义锁变量

pthread_mutex_init 初始化锁

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
           const pthread_mutexattr_t *restrict attr);
  • attr:锁属性,传NULL代表默认属性
  • 作用:把锁初始化成未上锁状态
  • 简写方式(静态初始化,全局锁推荐):pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock 加锁(阻塞)

int pthread_mutex_lock(pthread_mutex_t *mutex);
  • 如果锁空闲:拿到锁,继续往下走
  • 如果锁已经被别人持有:阻塞挂起,卡在这一行,直到锁释放

pthread_mutex_trylock 尝试加锁(非阻塞)

int pthread_mutex_trylock(pthread_mutex_t *mutex);
  • 锁空闲:拿到锁,返回 0
  • 锁被占用:直接返回EBUSY,不会卡住线程
  • 适合不想阻塞等待的场景

pthread_mutex_unlock 解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 释放锁,唤醒等待这个锁的其他线程
  • 谁加锁,谁解锁;不能解别人的锁

pthread_mutex_destroy 销毁锁

int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 释放锁内部资源
  • 静态初始化PTHREAD_MUTEX_INITIALIZER的锁,可以不 destroy

那么,我们现在就用一把锁,来修改一下我们的抢票代码

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
int ticket = 100;
void *thread_run(void *argc)
{
    char *id = (char *)argc;
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (ticket > 0)
        {
            usleep(100);
            printf("%s get ticket: %d\n",(char*)argc,ticket);
            ticket--;
        }
        else{
            break;
        }
        pthread_mutex_unlock(&mutex);
    }
}
int main()
{
    pthread_t id1,id2,id3,id4;
    pthread_create(&id1,nullptr,thread_run,(void*)"thread_1");
    pthread_create(&id2,nullptr,thread_run,(void*)"thread_2");
    pthread_create(&id3,nullptr,thread_run,(void*)"thread_3");
    pthread_create(&id4,nullptr,thread_run,(void*)"thread_4");
    sleep(100);
    return 0;
}

运行结果:

wuchun@VM-0-10-ubuntu:~/10_28$ ./ceshi1
thread_1 get ticket: 100
thread_1 get ticket: 99
thread_1 get ticket: 98
thread_2 get ticket: 97
thread_2 get ticket: 96
thread_2 get ticket: 95
thread_2 get ticket: 94
thread_2 get ticket: 93
thread_2 get ticket: 92
thread_1 get ticket: 91
thread_1 get ticket: 90
thread_1 get ticket: 89
thread_2 get ticket: 88
thread_2 get ticket: 87
thread_2 get ticket: 86
thread_2 get ticket: 85
thread_2 get ticket: 84
thread_2 get ticket: 83
thread_1 get ticket: 82
thread_1 get ticket: 81
thread_1 get ticket: 80
thread_1 get ticket: 79
thread_1 get ticket: 78
thread_1 get ticket: 77
thread_2 get ticket: 76
thread_2 get ticket: 75
thread_2 get ticket: 74
thread_2 get ticket: 73
thread_2 get ticket: 72
thread_2 get ticket: 71
thread_1 get ticket: 70
thread_1 get ticket: 69
thread_1 get ticket: 68
thread_2 get ticket: 67
thread_2 get ticket: 66
thread_2 get ticket: 65
thread_2 get ticket: 64
thread_2 get ticket: 63
thread_2 get ticket: 62
thread_2 get ticket: 61
thread_1 get ticket: 60
thread_1 get ticket: 59
thread_1 get ticket: 58
thread_2 get ticket: 57
thread_2 get ticket: 56
thread_2 get ticket: 55
thread_2 get ticket: 54
thread_2 get ticket: 53
thread_2 get ticket: 52
thread_1 get ticket: 51
thread_1 get ticket: 50
thread_1 get ticket: 49
thread_1 get ticket: 48
thread_1 get ticket: 47
thread_1 get ticket: 46
thread_2 get ticket: 45
thread_2 get ticket: 44
thread_2 get ticket: 43
thread_2 get ticket: 42
thread_2 get ticket: 41
thread_2 get ticket: 40
thread_2 get ticket: 39
thread_2 get ticket: 38
thread_2 get ticket: 37
thread_2 get ticket: 36
thread_2 get ticket: 35
thread_2 get ticket: 34
thread_1 get ticket: 33
thread_1 get ticket: 32
thread_1 get ticket: 31
thread_1 get ticket: 30
thread_1 get ticket: 29
thread_1 get ticket: 28
thread_2 get ticket: 27
thread_2 get ticket: 26
thread_2 get ticket: 25
thread_1 get ticket: 24
thread_1 get ticket: 23
thread_1 get ticket: 22
thread_1 get ticket: 21
thread_1 get ticket: 20
thread_1 get ticket: 19
thread_2 get ticket: 18
thread_2 get ticket: 17
thread_2 get ticket: 16
thread_2 get ticket: 15
thread_2 get ticket: 14
thread_2 get ticket: 13
thread_1 get ticket: 12
thread_1 get ticket: 11
thread_1 get ticket: 10
thread_1 get ticket: 9
thread_1 get ticket: 8
thread_1 get ticket: 7
thread_1 get ticket: 6
thread_1 get ticket: 5
thread_1 get ticket: 4
thread_1 get ticket: 3
thread_2 get ticket: 2
thread_2 get ticket: 1

可以发现,此时我们就不会出现ticket<=0的情况了,但同时,我们可以发现,我们的进程执行的速度慢了一些,这个时候,我们就要来说说锁的特性了

中间的绿色区域,就是我们的临界区,只有抢到锁的人,才能进入;如果我没有抢到锁,那我就返回,或者在抢锁的地方阻塞住,等抢到锁的人释放了锁,我才能继续去抢锁,直到抢到锁,才能访问临界区,所以这个过程,就是从并行,变成串行的过程,所以呀,我们锁要保护的区域要尽可能的小,不然会对我们的效率造成较大的影响

锁的原理:

lock:
    movb $0, %al
    xchgb %al, mutex
    if(al寄存器的内容 > 0){
        return 0;
    } else
        挂起等待;
    goto lock;

unlock:
    movb $1, mutex
    唤醒等待Mutex的线程;
    return 0;

好,我们现在来看看上面的汇编代码什么意思

首先呢,我们的线程在被调度的时候,寄存器中保存的就是我们的的线程上下文数据,而内存中我们定义了一个mutex的变量,其值为1,在我上锁的时候,我把我的al寄存器先置为0,然后我们交换al寄存器与mutex的值,从而mutex为0,al寄存器中的值为1,接下来,只有我al寄存器中的数大于0时,我才可以运行我下面的代码,否则我就挂起等待,然后我又重复此逻辑,继续尝试抢夺锁;

但是我想想,如果再发生我在运行的时候,CPU把我切走,调度别的线程怎么办?

完全没关系!

因为此时mutex;里面的值仍然为0,它的1作为我的被切走线程的上下文数据被带走了,所以就算调度别的线程,他也无法执行锁内代码,直到我被CPU切回来,运行完我的代码之后,然后释放锁

所以,抢锁的过程,就是我把公共资源占为己有的过程

而释放锁很简单,就是把mutex再置为1,就相当于公共资源的归还

那么我原本寄存器中不还是1么,没关系,上锁时会把他置为0

线程同步:

我先来给大家举一个例子吧?

今天我们学校有一个ssssssvip网吧包间,每次只有一个人可以进去,而我呢,我起得早,所以我六点起来,早早地就去了自习室,我把墙上挂着的钥匙取走,然后开始看书,耍手机?然后此时屋外慢慢的堆了一群人,但可惜,我这个自习室,它一次性只能有一个人可以进来(互斥),所以,他们虽然新生爱慕,但对我也不能怎么样,毕竟我遵守了规则.......然后过了一会,我想上厕所,我就拿起自习室的钥匙,然后挂回到原来的地方,这时候,别人就可以竞争这把锁,进去自习室自习了......但是!我比较贪心,我想着我一旦把这个锁释放掉,别人会不会进去?所以我释放完锁后,我赶紧又去申请锁,我刚出来,谁有我离锁更近呢?所以显然,我又会抢夺到锁;我循环往复,我抢了一百次锁,在这期间,我什么都没干,别人也进不去自习室......但他们能拿我怎么样呢,我并没有坏规矩,规矩只规定了,一次只有一个人可以进去,又没说我不可以重复竞争。所以啊,互斥它的确是可以的,但是它不一定正确;这时候,我们就要引入同步了

同步的概念是什么来着?

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

条件变量 pthread_cond_t

// 条件变量类型
pthread_cond_t cond;

pthread_cond_init

int pthread_cond_init(pthread_cond_t *restrict cond,
    const pthread_condattr_t *restrict attr);

初始化条件变量;attr 传 NULL 为默认属性。 静态简写:pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

pthread_cond_wait【重点】

int pthread_cond_wait(pthread_cond_t *restrict cond,
    pthread_mutex_t *restrict mutex);
  • 必须带着互斥锁进去调用
  • 动作:先解锁 mutex,然后阻塞等待信号;被唤醒后,自动重新加锁
  • 坑:存在虚假唤醒,醒来一定要重新判断条件,不能直接认为条件满足

所以啊,我们在这里一般在外面套一个while()

从而实现重新判断的目的

pthread_cond_signal

int pthread_cond_signal(pthread_cond_t *cond);

唤醒一个正在该 cond 上等待的线程

pthread_cond_broadcast

int pthread_cond_broadcast(pthread_cond_t *cond);

唤醒所有在该 cond 上等待的线程

pthread_cond_destroy

int pthread_cond_destroy(pthread_cond_t *cond);

销毁条件变量,释放资源;静态初始化的一般可不调用

小例子:

pthread_mutex_lock(&mutex);
while(条件不满足)   // while !不是if,防虚假唤醒
{
    pthread_cond_wait(&cond, &mutex);
}
// 条件满足,执行临界区
pthread_mutex_unlock(&mutex);
pthread_mutex_lock(&mutex);
// 修改共享数据,让条件变为成立
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

为什么我等待条件变量要在锁里?为什么要传入一把锁?

相信大家会有这样的疑问:

那么这里给出的解释是:

①:防止信号丢失

如果我刚判断条件不满足,进入循环之中,正要执行pthread_cond_wait的时候,我被切走了,此时我切到了另一个进程,另一个进程修改条件,执行pthread _cond_signal唤醒原线程,但是我原线程还没wait呢,所以此时我的这个信号就直接丢失掉了,会陷入永久的阻塞

②: pthread_cond_wait函数需要用到锁

我们这个函数内部分为几步:

首先我拿到这个锁,将其释放掉,让别的线程可以竞争这把锁,从而修改条件,发signal

然后阻塞休眠,等待唤醒

最后我被唤醒之后,最后重新获得这把锁,才会返回

同时:我们要知道,我们调用pthread_cond_wait和调用pthread_cond_signal函数的线程,他们所要进行修改判断的条件,必须是同一个锁来保护的;

正因为是同一把锁,所以才有pthread_cond_wait内部要释放锁,交给别的线程来让他们修改判断条件;

大例子:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t con = PTHREAD_COND_INITIALIZER;
int apple = 0;
// 生产者放苹果
void *putapple(void *arg)
{
    while(1)
    {
        pthread_mutex_lock(&mutex);
        apple++;
        printf("%s放了一个苹果\n",(char*)arg);
        pthread_cond_signal(&con);
        pthread_mutex_unlock(&mutex);
    }
    return nullptr;
}
// 消费者等苹果
void *getapple(void *arg)
{
    while (1)
    {
        pthread_mutex_lock(&mutex);
        while(apple<=0)
        {
            pthread_cond_wait(&con,&mutex);
        }
        apple--;
        printf("%s拿走了一个苹果\n",(char*)arg);
        pthread_mutex_unlock(&mutex);
    }
    return nullptr;
}
int main()
{
    pthread_t t1,t2;
    pthread_create(&t1,nullptr,getapple,(void*)"consumer");
    pthread_create(&t2,nullptr,putapple,(void*)"productor");
    sleep(100);
    return 0;
}

结果也是非常的和谐啊:

productor放了一个苹果
consumer拿走了一个苹果
productor放了一个苹果
consumer拿走了一个苹果
productor放了一个苹果
consumer拿走了一个苹果
productor放了一个苹果
consumer拿走了一个苹果
productor放了一个苹果
consumer拿走了一个苹果

那么到这里,我们的线程同步也结束了;接下来就是一些零零散散的小问题了;

线程安全

概念:

线程安全:就是多个线程在访问共享资源时,能够正确地执⾏,不会相互⼲扰或破坏彼此的执⾏结
果。⼀般⽽⾔,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。

重⼊:同⼀个函数被不同的执⾏流调⽤,当前⼀个流程还没有执⾏完,就有其他的执⾏流再次进⼊,我们称之为重⼊。⼀个函数在重⼊的情况下,运⾏结果不会出现任何不同或者任何问题,则该函数被称为可重⼊函数,否则,是不可重⼊函数。

那么二者的关系是:

函数如果是可重入的,那就是线程安全的

线程安全不⼀定是可重⼊的,⽽可重⼊函数则⼀定是线程安全的。

死锁:

四个必要条件,缺一不可

  1. 互斥条件 资源是独占的,一个资源同一时间只能被一个线程持有。比如互斥锁,拿到的人才能访问。
  2. 请求与保持(占有且等待) 线程已经拿着一部分资源,同时又去申请别的新资源,不释放手里已经拿到的。
  3. 不可剥夺 别人不能强行抢走线程已经占有的资源;只能线程自己主动 unlock 释放锁。
  4. 循环等待 线程之间形成资源等待环路:T1 等 T2 的资源,T2 等 T1 的资源,环闭合。

所以,四个条件分别是:互斥、占有等待、不可剥夺、循环等待

那么显然,只要破坏这四个条件不就破坏了死锁了?随便破坏一个死锁就不成立了

如何破坏:

  1. 破坏互斥:尽量使用共享资源;比如读写锁读模式可以共享。很多锁场景这个很难破坏,一般不优先选。
  2. 破坏请求与保持:一次性申请所有需要的资源。要拿锁 A + 锁 B,就一次性申请到两个才往下跑,不拿到 A 之后再去申请 B。
  3. 破坏不可剥夺:支持抢占。比如 pthread_mutex_trylock,拿不到直接返回,主动释放自己已经持有的锁,不卡死等待。
  4. 破坏循环等待:统一锁的获取顺序,所有线程永远按照固定顺序申请锁(先 A 后 B),环路形成不了。

尾:

哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈,其实现在已经神志不清了,这篇应该是写了六个小时多一点,本来要分为两篇写来着,后来因为太懒忘记了,所以今天只能一篇全写上了,明天应该会手搓线程池了,会把过程也发出来,作为这一块的大总结的;最近天气转凉,大家多穿衣服呀~

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2601_96394870/article/details/169257657

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--