Linux_线程

Linux线程的基本结论

  1. 从概念角度理解:线程是进程内部的一个执行分支,进程是内核数据结构 + 代码和数据,而无论线程还是进程,均为执行流

  2. 从内核与资源的角度理解:线程是CPU调度的基本单位,而进程是承担分配系统资源的基本实体

  3. 进程访问的大部分资源,都是通过虚拟地址空间拿到的!-》虚拟地址空间相当于资源的窗口-》以往提到,每个进程都有一份独立的虚拟地址空间;而线程指的就是多个task_struct(进程PCB)共享一个mm_struct(虚拟地址空间)这里的共享地址空间的进程就是Linux中的线程(如下图)-》而这里提到的划分虚拟地址空间其实就是通过划分页表实现的

  4. 其实,Linux内核中没有真正的线程,这里的线程是Linux通过进程模拟实现的(即所谓的轻量级进程),而只需要让每个线程执行不同的函数,即可实现多线程-》学习线程之前写的所有程序全称为"只有一个线程的进程"

  5. Linux使用进程模拟线程的原因:上述已经提到过,进程和线程其实都是执行流,都需要优先级,占用CPU,也就是说,线程和进程其实是有许多相同之处的若单独设计线程控制模块,则必定会与进程相关模块造成冗余,且单独设计线程模块鲁棒性不一定够高,但若采取被反复验证多年的进程模块来模拟,那鲁棒性一定会大大提高,正因如此,Linux索性采取了进程模拟进程的方式,实现代码复用的同时,也方便管理-》而模拟出的线程的正式名称为"轻量级进程"-》举例理解进程与线程的关系:进程相当于家庭,线程相当于家庭中的成员,整个家庭(进程)都有同一个最终目标,而这需要每个成员(线程)各自完成自己的任务来实现

    注:值得一提的是,Windows的线程模块是单独设计的

  6. 5中用家庭来举例,其实还为了凸显:进程强调资源独占(每个家庭使用的资源基本都是不同的-》也会有相同的,比如进程间通信使用共享内存),而线程强调共享(虽然每个人的任务不同,但会共用一个洗衣机,厨房等)

  7. 线程与进程的重点区别

    • 线程要有自己独立的上下文数据-》线程是可以被独立调度的
    • 每个线程都有自己独立的栈结构-》线程是一个动态的概念(有生命周期)
  8. 被线程共享的进程资源-》大部分资源都被共享:

    • 文件描述符表
    • 每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
    • 进程的当前工作目录
    • 用户id和组id
  9. 当线程崩溃时,整个进程都会崩溃

  10. 对于多线程进程,为避免出现创建超多线程的恶意程序导致进程饿死,每个线程会等分该进程的时间片,也就是说,该进程的所有线程的时间片总和为进程的时间片

  11. 一个进程的全局变量或全局函数是所有线程共享的-》因此一定要重点关注线程更改全局变量的行为,避免bug(数据不一致)

虚拟内存映射原理(以x86为例)

  1. 在大多数操作系统中,无论是磁盘、物理内存还是虚拟内存,操作系统进行空间管理都是以4KB为单位进行的(这个单位被称为页框或页帧),而每个程序的虚拟地址空间总大小为4GB,也就是说,对于一个程序,操作系统需管理4GB(虚拟地址空间大小) / 4KB = 1048576个页帧,而管理物理空间的单个页帧的结构体为page(如下):

    注:page管理的是物理空间,虚拟地址空间可通过页表转换到物理由page间接管理

    cpp 复制代码
    /* include/linux/mm_types.h */
    struct page {
    /* 原子标志,有些情况下会异步更新 */ 
    unsigned long flags;
    union {
    struct {
    /* 换出页列表,例如由zone->lru_lock保护的active_list */ 
    struct list_head lru;
    /* 如果最低为为0,则指向inode 
    * address_space,或为NULL 
    * 如果页映射为匿名内存,最低为置位 
    * 而且该指针指向anon_vma对象 
    */
    struct address_space* mapping;
    /* 在映射内的偏移量 */ 
    pgoff_t index;
    /*
    * 由映射私有,不透明数据 
    * 如果设置了PagePrivate,通常用于buffer_heads 
    * 如果设置了PageSwapCache,则用于swp_entry_t 
    * 如果设置了PG_buddy,则用于表示伙伴系统中的阶 
    */
    unsigned long private;
    };
    struct { /* slab, slob and slub */
    union {
    struct list_head slab_list; /* uses lru */
    struct { /* Partial pages */
    struct page* next;
    #ifdef CONFIG_64BIT
    int pages; /* Nr of pages left */
    int pobjects; /* Approximate count */
    #else
    short int pages;
    short int pobjects;
    #endif
    };
    };
    struct kmem_cache* slab_cache; /* not slob */
    /* Double-word boundary */
    void* freelist; /* first free object */
    union {
    void* s_mem; /* slab: first object */
    unsigned long counters; /* SLUB */
    struct { /* SLUB */
    unsigned inuse : 16; /* 用于SLUB分配器:对象的数目 */ 
    unsigned objects : 15;
    unsigned frozen : 1;
    };
    };
    };
    ...
    };
    union {
    /* 内存管理子系统中映射的页表项计数,用于表示页是否已经映射,还用于限制逆向映射
    搜索*/ 
    atomic_t _mapcount;
    unsigned int page_type;
    unsigned int active; /* SLAB */
    int units; /* SLOB */
    };
    ...
    #if defined(WANT_PAGE_VIRTUAL)
    /* 内核虚拟地址(如果没有映射则为NULL,即高端内存) */ 
    void* virtual;
    #endif /* WANT_PAGE_VIRTUAL */
    ...
    }
    • 由于单个page需要描述一个4KB的空间且需要管理的单位空间相当多,所以page本身的大小不能过大
    • 操作系统底层会使用struct page mem[1048576]来对这些page进行管理,也就是说,对空间的管理就转换为了对数组的管理,但是,观察发现,page结构体中并没有存储其管理的页帧的起始地址,那么,操作系统是如何构建page与页帧的映射关系呢?答案是:mem[index]表示一个page元素,而index * 4KB就是该page所对应的页帧的起始地址,这样,虽然page不位于其管理的页帧中,但通过天然映射,能够确保page与页帧的一一对应,而index * 4KB + 页帧偏移量就能访问到该页帧的全部位置
    • 像是申请空间时,本质就是更改页帧对应的page中的flags标志位,又比如快速查询一个未被使用的页帧时,可以通过将page元素再存入其他数据结构实现(如hash)-》即一个节点可以被存储到多个数据结构中,以实现不同的需求-》即一个实体,多种视图
    • 介绍下page结构体中的重点元素:
      1. flags :用来存放页的状态。这些状态包括页是不是脏的,是不是被锁定在内存中等。flag的每一位单独表示一种状态,所以它至少可以同时表示出32种不同的状态。这些标志定义在<linux/page-flags.h>中。其中一些比特位非常重要,如PG_locked用于指定页是否锁定,PG_uptodate用于表示页的数据已经从块设备读取并且没有出现错误
      2. _mapcount :表示在页表中有多少项指向该页,也就是这一页被引用了多少次,当计数值变为-1时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它-》这里与写时拷贝/缺页中断有很大关系,写时拷贝:当访问一个内存页时,发现权限位为r,且引用计数不为1,且虚拟地址指向的位置是数据区,就会进行写时拷贝;缺页中断:当虚拟地址合法,但映射关系没有建立完整,就进行缺页中断,建立映射;异常:当具备映射,但权限不对(如用户权限访问内核数据),就会产生异常
      3. virtual :是页的虚拟地址,通常情况下,它就是页在虚拟内存中的地址。有些内存(即所谓的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的时候,必须动态地映射这些页-》用于实现从物理地址映射到虚拟地址,具体来说,当想要获得一个物理地址的起始虚拟地址时,先将物理地址的后12bit清零获得页帧的物理首地址,然后将该物理地址 / 4KB即可获得管理该页帧的page结构体,而page结构体中又存储了虚拟地址的首地址-》至此,便实现了从物理地址映射到虚拟地址
  2. 虚拟地址映射至物理地址的方式与过程(如下图):

    origin_url=image-2.png&pos_id=img-v2FdFLNH-1775918040005)

    • 关于这张图,首先,为什么页表不是单张页表呢?因为页表本身需要快速访问,那就需要以数组的形式存储虚拟与物理的映射,但是,若对每个物理地址都使用两个具体地址进行映射的话(一个虚拟,一个物理),那就需要巨量的空间(一个字节需要4 + 4 = 8字节(假设是x86),那么4GB的空间就需要32GB的页表),这显然是不合常理的,那么,就需要虚拟和物理地址不能进行无关联的字节映射,也就是说,需要通过虚拟地址的某些位映射到物理地址的对应位来建立映射,这里就引出了多级页表,多级页表通过一个页目录(其中存储的是页表的首地址,页目录的下标为虚拟地址的高10位),找到对应的页表(其中存储页框的首地址,页表的下标为虚拟地址的11 ~ 20位),而页表中存储的是对应物理页帧的4KB的首地址,然后通过4KB + 虚拟地址剩余的12位二进制(正好是4096字节即4KB)获取物理地址,同时,这种办法还能确保程序无需映射4GB空间时,直接将页目录对应位置的页表指针置空来节省空间,当需要时再单独开辟->举例来说,当需要将虚拟地址0000 1111 0000 1101 0101 1100 1110 1111映射到物理地址时,需要先通过虚拟地址的前十位二进制0000 1111 00为下标获取页目录中的元素(对应页表的首地址),然后通过该页表首地址 + 虚拟地址的11 ~ 20位二进制00 1101 0101作为下标获取页表中的元素(对应页框的首地址),然后通过页框首地址 + 虚拟地址的21 ~ 32位二进制1100 1110 1111 + 从页表中获取的页框的首地址得到对应的物理地址
    • 上述映射过程,主要通过CPU中的CR3寄存器与MMU完成,其中CR3的功能为存储页目录的起始地址,MMU的功能为获取CPU收到的虚拟地址,再借助CR3中存储的该进程的页目录的起始地址,将虚拟地址映射为物理地址(即图中的第一阶段和第二阶段),最终,CPU就会返回物理地址给用户程序
    • 通过上述流程,其实可以发现一个规律-》当多个虚拟地址的前20位相同时,那么它们对应的物理地址将处于同一个页帧(即聚集效果好),这样,也就便于局部性原理的实现了(即,当访问第20行代码时,其周边代码也可能被访问,操作系统会将其周边代码也加载到内存中)
    • 细节补充:对于正常进程,是不会用完整整4GB的内存空间的,也就是说,实际页表的数量其实是远远小于1024个的,这也就确保空间不会分配到用不到的页表,即可避免资源的浪费(单级页表的话,因为必须用数组达成快速映射,所以没有办法节省不需要的空间,必须一次性开辟4MB来管理全部页帧),与此同时,其实页表中的元素仅需前20bit位存放物理地址即可,剩余12位地址使用虚拟地址的后12位即可,那么,这样页表中元素其实会剩余12bit未被使用,而操作系统可以将这些bit位作为页表的标志位,表示页表的权限等信息
    • 关于缺页中断:操作系统收到一个虚拟地址,会先检验该地址是否合法(是否位于4GB中),若合法,则会使用CPU进行物理地址的查找映射,当发现映射关系不存在时,就会陷入内核,发生缺页中断等行为,进行地址的映射构建
    • 注:虽然对于4KB,虚拟和物理地址的映射是连续的,但从整体来看,拿到的多个物理页帧大概率是不连续的,即这些页帧会分散到物理内存各处
  3. MMU要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当页表变为N级时,就变成了N次检索+1次读写。可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低,因此,操作系统引入了后备缓冲器(TLB)(也被称为快表),提前存储映射关系,这样,当需要使用虚拟查找物理时,CPU会先区TLB中查询,若查询失败,再通过页表进行查询

线程的优缺点

优点(相比进程更为轻量化)

  1. 创建一个线程的损耗要比创建一个进程小得多,原因是,创建进程需要申请PCB,而线程仅需要获取PCB中部分数据即可
  2. 与进程切换相比,同一进程中的线程切换要快很多,原因是,进程切换需要切换PCB,CR3的页表起始地址等,而线程切换时,若该线程依旧属于当前进程(通过页表起始地址可判断),则无需切换上述数据,但更重要的是,切换进程会导致cache缓存整体作废(cache缓存是操作系统为了避免内存与磁盘的频繁交互设计的一个缓冲区,功能是通过特定算法将将来可能被访问的数据提前写入内存),同时,上面页表中提到的TLB快表也会彻底失效,而同进程的线程切换时,不会出现这两者大量失效的情况
  3. 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
  4. 计算密集型应用(如加密,解密,压缩,GPU计算等操作),为了能在多处理器系统上运行,将计算分解到多个线程中实现
  5. I/O密集型应用(如从网络下载与磁盘交互等),为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作

缺点

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

pthread原生线程库

首先,要明确一点,由于Linux中是使用轻量级进程模拟实现的线程,所以本身没有系统调用专用管理线程,为了解决该问题,Linux提供了pthread.h原生线程控制库,可以将该库理解为Linux将轻量级进程封装为线程的接口库,也因此使用gcc/g++编译时,需要加上-lpthread/-pthread参数(推荐使用-pthread)以便编译时引入该库名称,值得一提的是,学习动静态库时使用过-L选项,其功能为告知编译器头文件的路径,若不使用-L指定,则会去系统默认路径下寻找,这里不使用-L就是因为pthread库已经处于系统默认路径了,而-l选项是必须的,无论能否在默认路径下找到该库,都需要指定库名称(这里-pthread是特殊处理,包含了-lpthread的功能)接下来重点介绍下相关接口:

  1. pthread_create:函数原型为int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);功能是创建并运行新线程,第一个参数为pthread_t类型的线程tid的指针,为输出型参数,第二个参数功能是线程属性,用于控制线程的栈大小等,一般可设置为nullptr进行默认设置,void *(start_routine)(void *)为线程创建后要执行的函数(返回值为void*类型-》使用void*类型的优点是该类型可以传递任意类型的变量/对象),void *arg为执行函数的void* 参数(使用void*类型的优点是该类型可以传递任意类型的变量/对象,void*也意味着,若需传递多个参数,则可以将这些参数打包成类,传递该类对象,但使用void 指向的数据前,需要先正确进行强转-》void*与其他指针类型的相互转换需要使用static_cast关键字
  2. pthread_join:函数原型为int pthread_join(pthread_t thread, void **value_ptr);默认情况下,线程结束后是需要等待的(joinable),而join函数的功能就是回收线程(类似进程的wait系列函数),若不回收线程则会造成内存泄漏,第一个参数为线程tid,第二个参数为输出型参数,表示获取线程执行结束后routine函数的返回值(注意这里的参数应为二级指针,因为routine函数的返回值为一级指针),该参数也可设置为nullptr,表示不接收routine函数的返回值
  3. pthread_detach:函数原型为int pthread_detach(pthread_t thread);功能是分离线程(可用于主线程分离新线程,也可用于新线程主动分离),分离线程后,线程将无需join函数等待,结束后会自动回收,参数为线程tid,且当线程被分离后,就算调用join也会失败
  4. pthread_exit:函数原型为void pthread_exit(void *value_ptr);功能是退出线程,参数为线程执行结束的返回值,该参数可设置为nullptr,表示不返回值
  5. pthread_self:函数原型为pthread_t pthread_self(void);功能是返回当前调用该接口线程的tid
  6. pthread_cancel:函数原型为int pthread_cancel(pthread_t thread);功能是取消线程,参数为线程tid,且一般情况下用于父终止子(理论上也可子终止父,但父终止即main线程终止,那进程就会结束,导致该进程全部线程都结束),且cancel后的线程若未分离则仍需join,而cancel后join的第二个参数接收到的值为-1(PTHREAD_CANCELED宏)
  7. pthread_setname_np:函数原型为int pthread_setname_np(pthread_t thread, const char *name);功能是设置线程名称,参数为线程tid,第二个参数为线程名称,该名称长度不能超过16字节(包括\0),且线程名称不能为空-》该名称变量会在内核管理轻量级进程的数据结构中保存一份,也会在线程局部存储区域创建一份
  8. pthread_getname_np:函数原型为int pthread_getname_np(pthread_t thread, char *name, size_t len);功能是获取线程名称,参数为线程tid,第二个参数为线程名称,第三个参数为线程名称长度,该名称长度不能超过16字节(包括\0),且线程名称不能为空

线程接口使用注意事项:

  1. pthread系列函数的返回值一般均为0表示成功,失败将返回错误码(也就是说不会设置全局errno,而会将错误码作为返回值)-》因此常使用if(n != 0)进行错误判断
  2. pthread_t tid本质是地址,与ps -aL得到的LWP是不同的(目的是解耦线程与轻量级进程)
  3. 线程函数需使用return/pthread_exit进行返回或pthread_cancel取消线程,但不能使用exit(因为这会导致整个进程退出)
  4. 线程退出不会考虑是否是正常退出的,因为线程发生除零异常等问题会直接导致进程退出,就算返回也不会被join到

详细理解线程在进程中的实现方式



  1. 首先,链接pthread动态库时,会使用进程的共享区虚拟地址,当多个进程链接时,一份pthread会被多个共享区链接
  2. create创建一个线程后,会在pthread库中创建一个struct _pthread结构体(线程控制块-》类似进程PCB)(线程局部存储和线程栈是在其他时刻申请的,这里会先创建pthread结构体-》就是说虽然这些空间都是在create函数中申请的,但地址不连续),该结构体中的内容包含线程状态,线程id(即pthread_t类型的tid),线程独立栈结构的首地址,线程栈的大小,线程的返回值等,而create函数内部会调用clone系统调用,使操作系统创建一个轻量级进程,也就会创建LWP,而LWP中包含了该轻量级进程的调度优先级,时间片,上下文等内容,以此解耦线程与轻量级进程,但线程本身是需要struct_pthread与LWP共同管理的,同时可以观察到clone的参数需要routine函数的地址以及刚创建的线程的独立栈结构的起始地址-》clone函数会在该栈进行轻量级进程的执行(routine函数也会在该栈中执行)(注意clone函数的第二个参数栈地址需要传递栈的起始地址 + 栈大小-》因为栈是向下增长的)
  3. 其实tid就是struct_pthread的首地址
  4. 每个线程都有自己独立的栈结构-》主线程会使用虚拟地址空间的栈,而新线程会使用位于共享区的独立的线程栈
  5. 由于线程的返回值会以void*的形式被存储到struct _pthread中,所以需要使用二级指针进行接收
  6. 新线程结束后,轻量级进程会结束,但struct _pthread,线程局部存储,线程栈等空间默认情况下是不会被自动释放的,这也就是需要使用join函数进行回收的原因,值得一提的是,join的参数之一为tid,而tid又是struct _pthread的起始地址-》类似malloc与free
  7. Linux中用户级线程(struct _pthread)与内核级线程(LWP)是1 : 1的,其他操作系统可能使1 : N的
  8. 线程控制块(strcut _pthread)中的线程状态jointabe为1时,表示线程(轻量级进程)退出后需要join释放空间,若该值为0,则线程退出后会自动释放
  9. 由于所有进程都会映射到pthread库,而struct _pthread又位于pthread库中,所以Linux的所有线程控制块都位于pthread库中,只不过进程之间不能互相访问(因为虚拟地址空间将进程与进程的线程控制块隔离了)
  10. 主线程栈位于虚拟地址空间的栈中,是可以动态增长的,只有超出增长上限才会报错,而新线程栈是位于共享区大小是确定的,不能动态增长,同时需要注意,由于所有新线程的栈都位于进程的共享区,所有只要同进程的其他线程知道新线程的栈地址,就可以拿到新线程的数据
  11. 线程局部存储:在全局变量前添加__thread修饰,会将该变量存储到各个线程的局部存储中,使得每个线程都有自己单独的一份,但只能用于存储内置类型和部分指针,不能用于存储对象

线程相关命令

  1. ps -aL:查看所有线程,其中PID表示进程ID,LWP表示轻量级进程ID,当进程为单线程时,LWP与PID相同,但是,其实操作系统调度时使用的是LWP而非PID,同时需要注意,这里的线程ID与pthread库的tid完全不同

线程互斥

共享资源

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

在前面创建多线程进行打印时,经常会遇到信息乱序输出到标准输出的情况,其原因其实就是多个线程同时访问了同一资源,更典型的情况时,当多个线程访问公有资源时,就会产生数据竞争,导致数据不一致问题,具体来说,一个抢票程序,若定义全局变量count作为票数,则多个线程进行if(count > 0) --count;操作时,最终count可能为负数,具体分析其原因的话,我们都知道,CPU执行进程的最小单位是一句汇编指令,而在Linux分时系统中,不一定执行到那条汇编进程时间片就到了,就会转而调用下一个进程,而对于if(count > 0) --count;首先说明,--count其实本质是三条汇编,第一句的功能是从内存拿count的值到CPU,第二条是进行--count计算,第三条是用计算结果覆盖内存中count的值,而产生负数的一种可能的真实过程是,当count == 1时,进程A进入了if语句,然后,进程A被中断,进程B执行if,发现CPU中的count为1,因此执行--count,执行完毕后,内存中的count被更改为了-1,接着继续执行进程A的三条汇编,--count执行完毕后,count就被更改为了-1(注:由于线程就是轻量级进程,所以这里同一使用了进程称呼)

而关于上述出现负数的现象,需多线程(进程)尽可能多的制造并发与进程切换才能观察到,先补充下进程切换操作系统的主要工作,CPU上下文做保存,地址空间,页表,PCB等做切换,而进程什么时候会切换出CPU呢?答案是:1.时间片到了、2.调用了阻塞式IO、3.挂起或休眠的函数(如sleep, usleep等,原因是休眠则无需使用CPU,当其睡眠时,CPU会直接转而调度其他进程/线程);那么,什么时候会切换就绪队列中的进程到CPU呢?当从内核态返回用户态后会进行检查,没错-》此时不光会做信号的检查,还会做进程调度的检查

互斥锁(互斥量)

互斥锁的相关接口

使用时需包含pthread.h头文件,首先明确,在POSIX(pthread的标准规范)中规定,pthread库中所有函数都应成功返回0,失败返回错误码,而不设置errno,而互斥锁常用接口如下:

  1. pthread_mutex_t mutex; 这是互斥锁的类型,无论是使用局部还是全局,这都是第一步,然后,若需要使用全局锁,则需要使用PTHREAD_MUTEX_INITIALIZER宏进行初始化(注:只能全局锁使用,局部不可以)
  2. int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr),功能是初始化局部创建的互斥锁,第一个参数为提前创建的锁的地址,第二个参数为锁的属性,基本无需设置,传nullptr即可
  3. int pthread_mutex_destroy(pthread_mutex_t *mutex),功能是销毁局部创建的互斥锁(全局锁无需调用该函数,会自动销毁,但局部锁使用完毕后必须调用该函数)
  4. int pthread_mutex_lock(pthread_mutex_t *mutex),功能是上锁,即获取锁,若锁被其他线程占用,则当前线程会阻塞,直到锁被释放为止,成功则继续执行后续代码,参数为申请的锁的指针
  5. int pthread_mutex_unlock(pthread_mutex_t *mutex),功能是解锁,即释放锁(lock后一定要记得unlock),若当前线程没有获取锁,则返回错误,参数为申请的锁的指针
  6. int pthread_mutex_trylock(pthread_mutex_t *mutex),是lock函数的非阻塞版本,功能是尝试上锁,即获取锁,若锁被其他线程占用,则不阻塞,直接错误,参数为申请的锁的指针,这里暂不考虑

互斥与互斥锁的实现原理

实现互斥一般有两种办法:一是硬件级实现:关闭时钟中断(很少用,甚至基本不会用),二是软件级实现:使用锁,而锁的原理将会通过下图伪代码进行讲解:

率先说明,其实可以暂且将锁当作一个标志位,初始情况下其值为1,当锁被占用时,其值为0

  1. 首先,执行movb $0, %al,该语句的功能是,将通用寄存器al中的值设置为0,即al = 0,这里需要注意的是,al这个寄存器很常用,经常会被改变
  2. 然后,执行xchgb %al, mutex,该语句是lock的重点,功能是交换%al与mutex中的内容,值得一提的是:该语句是原子的!,且由于是交换操作,所以1最多只可能有1个
  3. 判断当前al寄存器的内容,若大于0,则会执行临界区的代码,否则,会挂起等待,知道metex被释放(其中值回到1)
  4. 执行完毕后返回lock处,继续竞争锁
  5. 而关于unlock操作,move $1 mutex就是将mutex的值设置重新为1,即释放锁,这里需要注意,之所以不使用swap(%al, mutex),是因为%al是非常常用的寄存器,极有可能在执行共享区代码期间被更改
  6. 然后唤醒阻塞等待mutex的线程,让他们继续竞争锁

如此,便可以保证锁仅被一个线程拿到,而不会出现临界资源错误

互斥锁的补充说明

  1. 加锁的范围(lock和unlock之间)要尽可能的不要包含非临界区的代码
  2. 所谓的对临界资源进行保护,本质就是将临界区放到lock与unlock区间中
  3. 锁能力的本质,就是将执行的临界区代码由并行转换为串行(在执行期间,不会被打扰,也是一种变相的原子性表现)
  4. 加锁之后,在临界区内部,是允许线程切换的,但由于锁的机制,就算切换了也能保证原子性,当前简单理解就是,因为就算当前线程被切换,也没有释放锁,即线程是在持有锁的状态下被切换的,此时,就算调度其他线程,因为无法得到锁,所以其他线程也不能进入临界区
  5. 仅仅互斥是存在问题的,比如需求是A提供资源,B消费资源,若仅仅使用互斥,那A提供完资源后可能仍会持续与B竞争锁,竞争成功但发现资源还在时,A就会解锁并继续与B竞争,这显然会造成资源的浪费,为此,产生了线程同步(条件变量)

生产者消费者模型

概念及记忆

总结:"321"原则(非术语,面试注意)

  1. "3"指的是三种关系:消费者和消费者之间构成互斥(竞争关系);生产者与生产者之间构成互斥(竞争关系);消费者与生产者之间构成互斥和同步关系
  2. "2"指的是两种身份:生产者和消费者
  3. "1"指的是一个交易场所:以特定结构构成的一个"内存"空间

线程采取该模型的优点

  1. 生产过程和消费过程相互解耦(即各自独立)-》工厂生产的同时消费者可以使用得到的产品
  2. 支持忙闲不均-》因为有缓存的存在,所以可以避免生产者等消费者,也能避免消费者等生产者
  3. 提高效率:不是体现在交易过程中的,而是体现在未来获取任务和处理任务是并发的(生产和消费的过程是串行执行的),比如说,任务的获取和处理需要大量耗时,若单线程,则可能会阻塞任务的获取,但此时其实是有获取到的任务需要被执行的,此时若使用多线程则可以一边等待任务的获取,一边处理任务,大大提高效率

线程同步(条件变量)

引入一个解决方案,则必定引入新的问题,为了进一步解决问题,就必须有新的技术被引入,而这里提到的问题就是上述互斥锁的第五点,一个进程(线程)很可能持续竞争到锁,但若一直是生产者/消费者之一竞争到锁,然后发现缺少消费者拿走资源/缺少生产者提供资源,就会导致另一方被饿死,为了解决这个问题,提出了同步机制:当生产者竞争到锁后,若发现其中资源没有被消费者拿走,则释放锁,并将该生产者放到阻塞队列中,若发现资源被消费者拿走了,则放入新资源,并向消费者通信

条件变量的相关接口

首先说明,条件变量相关接口的使用与互斥锁极为相似,同时,条件变量的使用还需要依赖互斥锁,而pthread_cond_t是条件变量的类型,若定义在全局,则需要使用PTHREAD_COND_INITIALIZER宏进行初始化,若定义在局部,则需要使用init函数进行初始化

  1. int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr),功能是初始化条件变量,第一个参数为条件变量的指针,第二个参数为条件变量的属性,一般传nullptr默认
  2. int pthread_cond_destroy(pthread_cond_t *cond),功能是销毁条件变量,参数为条件变量的指针
  3. int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex),功能是等待条件变量,第一个参数为条件变量的指针,第二个参数为互斥锁的指针
  4. int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime),功能是带超时等待条件变量,第一个参数为条件变量的指针,第二个参数为互斥锁的指针,第三个参数为超时时间
  5. int pthread_cond_signal(pthread_cond_t *cond),功能是唤醒一个等待条件变量的线程,参数为条件变量的指针,
  6. int pthread_cond_broadcast(pthread_cond_t *cond),功能是唤醒所有等待条件变量的线程,参数为条件变量的指针

线程同步(信号量)

概念及记忆

信号量本质就相当于一个用于描述临界资源数目的原子性的计数器(可原子++/--),外加线程/进程等待队列

信号量常用接口

这些接口均位于semaphore.h头文件中,且均遵循POSIX标准,但与pthread不同的是,sem系列函数成功返回0,失败返回-1并会设置错误码errno,信号量的类型为sem_t

  1. int sem_init(sem_t *sem, int pshared, unsigned int value),功能是初始化信号量,第一个参数为信号量的指针,第二个参数为信号量的属性,用于指定信号量是在进程间共享还是仅在线程间共享(0表示同进程的线程,非0表示进程),一般传0,第三个参数为信号量的初始值(即初始时资源的数量)
  2. int sem_destroy(sem_t *sem),功能是销毁信号量,参数为信号量的指针
  3. int sem_wait(sem_t *sem)功能是先原子检查信号量的值,当信号量的值为0时,该函数会阻塞,直到被唤醒后,会再次检验信号量的值,若为正数,则会将信号量原子--,并继续运行,参数为信号量的指针
  4. int sem_post(sem_t *sem)功能是让信号量的值原子++,然后检验++前信号量的值是否为0,若为零,则判断是否有等待的进程,若有,则唤醒进程,参数为信号量的指针
  5. int sem_trywait(sem_t *sem),是sem_wait的非阻塞版本
  6. int sem_getvalue(sem_t *sem, int *sval),功能是获取信号量的值,参数为信号量的指针,第二个参数输出型参数,获取的值将保存到该参数中,需要重点注意的是,该操作不是原子的!

使用定长数组实现的环形队列 + 信号量 + 互斥锁实现生产者消费者模型

环形队列需保证4个约定:

  1. 空,生产者先运行
  2. 满,消费者先运行
  3. 生产者不能套消费者一个圈以上
  4. 消费者不能超过生产者

解释:由于生产者和消费者处于环形队列同一位置的情况仅有资源为空或资源为满时,所以可通过信号量这个原子计数器进行判断(应使用双信号量,一个起始为最多容纳的资源数量,一个起始为0,分别用来阻塞生产者和消费者线程),计算可用资源的数量,当资源数量为满时,阻塞生产者,当资源为空时,阻塞消费者,这样就可避免消费者与生产者访问同一位置的资源,以保证线程安全,而既然如此,为什么还需要锁呢?答案是,信号量的作用仅为保证生产者与消费者之间的线程安全,而若涉及到多生产多消费,则需要使用互斥锁来保证生产者之间以及消费者之间的线程安全(因此需要两把互斥锁),而使用定长数组实现循环队列则可避免指向元素的指针失效导致扩容等情况造成错误访问

线程安全与重入问题

  1. 线程安全:描述的线程本身的健康或安全状态,指的是多个线程在访问共享资源时,不会相互干扰或破坏彼此的执行结果

  2. 重入:描述的是函数的特征,当一个函数同时被多个线程调用,此行为就称为重入,而重入后不会造成错误的函数,即为可重入函数,否则为不可重入函数

  3. 线程安全与重入的关系:当一个函数是可重入的,则它一定是线程安全的,不过需要注意,线程安全调用的函数,却不一定是可重入的,例如下图的函数,对临界资源加了锁,肯定是线程安全的,但是,如果在执行临界区期间进程被发送了信号,而信号处理函数中调用了该函数,就会导致信号捕捉函数拿不到锁(想拿锁只能让接收信号前的线程先释放锁,但若信号处理函数没结束则不可能让前面的线程执行),从而造成死锁问题,因此,就算该函数是线程安全的,但也并非是可重入的

  4. 一般情况下,当一个多线程函数仅访问局部变量时,无需加锁也能保证线程安全,但多个线程若同时访问全局变量或静态变量,则建议加锁保证线程安全

死锁

什么是死锁

死锁即为某些线程占据资源,因为释放的条件永远无法达成的现象,就比如说,进程A释放锁A的条件是拿到进程B的锁B,但进程B释放锁B的条件是拿到锁A,则两者就会造成死锁,锁A和锁B永远无法释放(该例子和智能指针循环引用的产生类似)

死锁产生的四个必要条件及死锁避免

  1. 互斥条件:一个资源每次只能被一个执行流使用
  2. 请求与保持:一个执行流请求其他资源,但不释放自己的资源
  3. 不剥夺条件:一个执行流获得的资源在未使用完之前不能被强行剥夺
  4. 循环等待条件:若干执行流间形成一种头尾相接的循环等待资源的关系

死锁避免:上述条件只要有一个不成立,就可以避免死锁

STL,智能指针与线程安全

  1. STL在设计时为保证效率,不是线程安全的,因此,使用时需要适当加锁
  2. 一般情况下,unique_ptr不会涉及到线程安全问题,而shared_ptr在设计时考虑了这个问题,底层采取原子操作的方式兼顾了高效与线程安全

补充知识

  1. 每条汇编指令都有自己的长度,下一条指令的起始地址就是当前指令地址 + 当前指令长度
  2. CPU调度进程的执行单位是汇编语句,当前可理解为"单条汇编就是原子的"(但注意实际情况并非如此)
    尾相接的循环等待资源的关系

死锁避免:上述条件只要有一个不成立,就可以避免死锁

STL,智能指针与线程安全

  1. STL在设计时为保证效率,不是线程安全的,因此,使用时需要适当加锁
  2. 一般情况下,unique_ptr不会涉及到线程安全问题,而shared_ptr在设计时考虑了这个问题,底层采取原子操作的方式兼顾了高效与线程安全

补充知识

  1. 每条汇编指令都有自己的长度,下一条指令的起始地址就是当前指令地址 + 当前指令长度
  2. CPU调度进程的执行单位是汇编语句,当前可理解为"单条汇编就是原子的"(但注意实际情况并非如此)
  3. 线程/进程切换时,CPU内的寄存器硬件只有一套,但CPU内的数据可以有多份,每个线程/进程都有自己的一份,而这就是所谓的上下文数据,而把一个变量的内容,交换到CPU寄存器内部,本质是,把该变量的内容,获取到当前执行流的硬件上下文中,而这上下文数据是进程/线程私有的
相关推荐
Elastic 中国社区官方博客2 小时前
如何使用 LogsDB 降低 Elasticsearch 日志存储成本
大数据·运维·数据库·elasticsearch·搜索引擎·全文检索·可用性测试
CDN3602 小时前
高防服务器端口被占用 / 不通?端口映射与协议配置解决
运维·服务器
2401_892070982 小时前
【Linux C++ 后端实战】异步日志系统 AsyncLogging 完整设计与源码解析
linux·c++·高并发·异步日志
梓䈑2 小时前
gtest实战入门:从安装到TEST宏的单元测试指南
c++·单元测试
2301_旺仔2 小时前
【prometheus】监控linux/windows
linux·windows·prometheus
郝学胜-神的一滴2 小时前
墨韵技术|CMake:现代项目构建的「行云流水」之道
c++·程序人生·软件工程·软件构建·cmake
雪域迷影2 小时前
Hazel游戏引擎结构分析
c++·游戏引擎·hazel
“愿你如星辰如月”2 小时前
从零构建高性能 Reactor 服务器:
linux·服务器·c++·websocket·tcp/ip
腾讯蓝鲸智云2 小时前
提升研发效能:DevOps平台高效权限配置与同步方案
运维·服务器·人工智能·云计算·devops