13 线程
13.1 什么是线程
-
我们知道,常规情况下,一个进程只有一个执行流,这意味着这个进程只能一次次按顺序执行代码,就像是一条流水线一样,需要执行的代码从上至下,抑或是循环执行,或者跳过着执行,但归根结底,就是一条执行流
-
那么线程是来做什么的呢?是来增加执行流的
-
换句话说,线程可以让一个进程不仅仅拥有一个执行流,而是可以让进程同时拥有多个执行流,就像是并发执行(并非严格的并行执行,而是人类感受不到切换的速度,认为其在并行执行,这也是并发的含义)一样,我们知道,一个进程运行的起点是
main(),而其他线程执行的起始位置就一定不是main()了,而是其他函数 -
这么说可能还是有点模糊,不过我们可以暂时输出一个结论:我们之前谈论的进程的执行流只有一个,这意味着这个进程包含着一个线程!
-
看懂了么,线程和进程并非冲突关系,而是包含关系!意味着,一个进程包含多个执行流其实本身就是理所应当的!
-
换句话说,一个进程使用多线程运行本身就应该是常态,单线程运行是一种特例!
-
那么,你说线程的执行起始位置是非
main()的其他函数,是否意味着,有很多资源,包括库,包括全局的函数,变量等等都是可以被其他线程读取到的呢? -
是的!这意味着,对于线程来讲,所有公共资源都是可以被读取到的!
-
所以,你知道为什么我着重强调加锁,原子性的重要性了吗?因为进程可以有多个执行流!!
-
那么,我们再讲一个事实:即线程才是
CPU执行的执行流的基本单位 -
那么,我们先前了解到过,内核要找到进程,需要通过
PCB,因为它是描述进程的结构,意味着需要执行一个执行流,需要找到它的PCB,但事实是,在Linux中,一个进程和线程的区别被淡化了 -
为什么?
-
传统的,认为进程是单执行流,那么
PCB就是用来描述单执行流的,如果想要将进程改为运行多执行的形式,那么无异于有两种做法:- 明确规定
PCB就是用来描述进程的,如果要改成多执行流,你得在PCB中再加一个结构用于描述单个执行流,另一个结构组织这些单执行流变成多执行流,并且修改内核调度逻辑,调度进程改成寻找PCB中的执行流,那么此时PCB就不是用来描述单执行流的了,而是描述进程这个整体的了 - 我们知道传统
PCB是用来描述单进程的,那么为什么不直接在进程中增加多个PCB呢?这样原来的调度逻辑就不需要修改,然后还能得到一个可以有多执行流的进程
- 明确规定
-
那么,以上的两种方式,都是实现线程的方案,且都有原型:第一种方案是
Windows的方案,而第二种方案则是Linux的方案 -
换句话说
-
Windows的程序员认为PCB就是用来描述进程的,需要和线程分开 -
linux的程序员认为PCB是用来描述执行流的,且直接使用现成的结构会更讨巧,减少工作量,也能减少Bug(因为已经使用验证过稳定性的东西总比自己造一个轮子来得更安全) -
用一个不那么严谨的讨巧的话来讲:就是
Linux用进程模拟了线程
13.2 题外话:为什么Windows的线程设计和linux的线程设计有区别?
-
因为在操作系统的研究中,程序员只知道设计方向是什么,换句话说程序员只知道要实现线程的功能,具体怎么实现其实关系不大,能完成任务,实现功能就行,有句经典的话是:程序设计,从来都是先得让程序跑起来,写漂亮的代码,优化等等都是后面的事情
-
不过后来的我们很清楚,有些操作系统的很多代码都是很远古的代码
-
不过总的来说,没有谁对谁错,因为操作系统学科从来都没有明确说过该怎样具体实现,只是有这个概念而已
-
同时,这也是不同程序员有着不同理念的体现
-
比方说这里的
Linux程序员贯彻着轻量化与稳定的核心要义去设计线程,即"线程"是轻量级进程 -
而
Windows程序员则更注重逻辑分层理念 -
所以:
Windows其实强调线程是进程的组成部分Linux强调线程是调度的最小单元
13.3 页表问题与内存管理
-
请注意,这个章节会比较抽象
-
在学习后面的内容之前,我们需要深刻理解一下页表这个东西(毕竟以前了解的页表其实并没有这么深刻)
13.3.1 树状页表结构
-
首先,我们要纠正一个观念,我们以前认为页表这个结构的具体内容无非是三个东西
- 虚拟地址
- 物理地址
- 权限
-
但事实并非如此,实际上,在现代操作系统中,"虚拟地址"和其他的两样是完全分开的!,并不在一个结构中!通过虚拟地址寻找物理地址也绝非像
Key&Value一样找起来这么容易,而是要经过非常多的计算才可以被找到的 -
传统页表其实跟我们之前学到的内容一样,他的形式就像是一个
vector<pair<void*, void**>>一样,是线性存储的 -
但随着内存技术的不断提升,这种存储方式会非常消耗内存,我们打个比方,假如说一个进程是4GB,那么光页表可能就会消耗将近4MB左右的空间,对于页表来说这太大了,并且这会造成一个问题,就是哪怕这个进程本身内存占用非常小,页表也会占用固定的4MB,有没有办法让页表在小进程中的占用能够减少呢?
-
于是就采取了一种策略,即树状页表结构
- 是否看起来非常抽象?没事,头晕是正常的,我们从源码开始详细解释一下
struct mm_struct {
// ...
unsigned long cached_hole_size; /* if non-zero, the largest hole below free_area_cache */
unsigned long free_area_cache; /* first hole of size cached_hole_size or larger */
pgd_t * pgd; // 注意这个玩意
atomic_t mm_users;
// ...
}
typedef unsigned long pte_t; // 32位和64位都可以用
typedef unsigned long pmd_t; // 64位内容
typedef unsigned long pgd_t; // 32位和64位都可以用
typedef unsigned long pgprot_t; // 64位内容
-
在32位
Linux系统中,存在由两种基本单元构成的表,即类似于vector<pgd_t>和vector<pte_t>的玩意 -
每个表占用的空间是
4byte(unsigned long的大小) * 1024个 = 4kb,所以一个表正好是一个块的大小 -
表中的每个元素本质上都是
unsigned long,其中,如果我们以16进制看,高5位代表着下一级表的地址,低3位是标志位,或者说是权限标志位 -
比方说一个元素的值是
0x12345007,那么这个元素所指向的下一级表的地址在0x12345000(物理地址),0x007表示该位置的权限 -
我们简单点说,其实就是多级表结构,一个上级表指向多个下级表,换句话说就是1024个
PGD表如何做到管理4GB的?是因为PGD表管理了1024个PTE,而每个PTE管理了4kB,所以正好就管理了4GB的空间 -
这样设计的好处是什么?即,如果我们只使用了很少的空间,那么就会有很多页表不需要开辟空间,换句话说,这个树可以不是一个完整的树,正是因为其不是一个满的树,所以他可以做到节约内存的目的
-
打个比方,如果一个进程只用到了30MB的空间,那么其页表最少需要大约
(30 + 1) * 4kb = 124kb,远比4MB小 -
但其实这种方式仅限于小内存,如果一个进程吃满了4GB,那么其页表要占到
(1024 + 1) * 4kb = 4MB + 4kb,实际上是比传统方式大的,不过嘛,哪个进程能吃这么多内存啊 -
那么,细心的你一定发现了一些问题:为什么表中元素的值可以靠截断来获取下一个页表的物理地址呢?这样不会获取不完整吗?
-
我们仔细看看,
0x12345000这个地址,和0x12346000这个地址,相差了多少空间?正好相差4kb的空间,所以你发现了一件什么事情?! -
其实页表在物理内存的存储是及其规律的!是符合内存对齐的!我们获取的只是表的起始位置而已!!!所以这相差的4kb,正好能放得下一个表!!!然后为了节约空间,甚至还能把后面的位当作权限标志位使用,简直是节约到家了
-
另一个问题:细心的你一定发现了,为什么
PTE的高位部分是一个叫做页框号的东西?这就必须提一嘴关于内核对于内存的管理了
13.3.2 内存管理
-
我们知道,无论在磁盘中也好,还是在物理内存中也好,实际上空间都被按照
4kb的块划分了,所有空间分配都需要以4kb对齐 -
所以,管理内存的本质,就是管理一个个的
4kb就行,所以在Linux中,存在一个叫做struct page的结构,我们来看看源码
struct page {
unsigned long flags; /* Atomic flags, some possibly
* updated asynchronously */
atomic_t _count; /* Usage count, see below. */
atomic_t _mapcount; /* Count of ptes mapped in mms,
* to show when page is mapped
* & limit reverse map searches.
*/
union {
struct {
unsigned long private; /* Mapping-private opaque data:
* usually used for buffer_heads
* if PagePrivate set; used for
* swp_entry_t if PageSwapCache;
* indicates order in the buddy
* system if PG_buddy is set.
*/
struct address_space *mapping; /* If low bit clear, points to
* inode address_space, or NULL.
* If page mapped as anonymous
* memory, low bit is set, and
* it points to anon_vma object:
* see PAGE_MAPPING_ANON below.
*/
};
#if NR_CPUS >= CONFIG_SPLIT_PTLOCK_CPUS
spinlock_t ptl;
#endif
};
pgoff_t index; /* Our offset within mapping. */
struct list_head lru; /* Pageout list, eg. active_list
* protected by zone->lru_lock !
*/
/*
* On machines where all RAM is mapped into kernel address space,
* we can simply calculate the virtual address. On machines with
* highmem some memory is mapped into kernel virtual memory
* dynamically, so we need a place to store that address.
* Note that this field could be 16 bits on x86 ... ;)
*
* Architectures with slow multiplication can define
* WANT_PAGE_VIRTUAL in asm/page.h
*/
#if defined(WANT_PAGE_VIRTUAL)
void *virtual; /* Kernel virtual address (NULL if
not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */
};
-
你能看到里面有很多联合体,这是因为这种描述块的结构,需要尽可能节约空间,否则占用空间会很大
-
很多东西都是我们不需要非常在意的,只有一个要注意的地方:
count:引用计数,指代有多少人在使用该块 -
而每一个
struct page结构,我们称作页框 -
问题来了,这个页框结构中并不储存物理内存的地址啊?他怎么能够描述某个块空间呢?
-
其实很简单,假设我们有4GB物理内存,那么就有
1024 * 1024个块,所以就会有1024 * 1024个struct page -
于是我们要管理这些
struct page,就需要将其放进一个结构中,比方说一个数组中,那么,放进数组就意味着有下标 -
于是,我们拿着
下标 * 4kb,不就是物理地址了吗? -
所以说,完全不需要储存物理地址,直接算出来就行
-
而这个数组的下标,我们就称为"页框号"!
-
所以
PTE的高位部分是页框号!CPU会根据页框号找到具体的struct page和具体的物理地址
13.3.4 如何通过一个虚拟地址找到物理地址?
-
拿32位操作系统举例
-
一个虚拟地址其实被划分为3个部分,比方说这里的
0x12345678,我们转成二进制就是0b0001 0010 0011 0100 0101 0110 0111 1000 -
其中:
0001 0010 00 11 0100 0101 0110 0111 1000
| | | | | |
----PGD号--- ----PTE号--- ----偏移量----
-
你会发现,
PGD和PTE号最大就是1024个数,这也正好应证了树状页表设计原则与思想 -
然后,我们算出
0001 0010 00实际十进制是72,所以我们访问PGD[72](这个数组的地址会被CPU的一个叫做CR3的寄存器记录,每次进程切换的时候,这个值都会变成需要切换的进程的页表的地址),可以拿到类似于这样的一串数0x12345007 -
暂时不管标记位,将其截断后成为
0x12345000,并将其强转为pte_t*并赋值给pte_t* PTE变量 -
计算
11 0100 0101实际十进制是837,所以访问PTE[837],可以得到另一串数0x01145007 -
按照同样的方式截断,得到
0x01145,换成十进制是4421,所以我们可以得到其块的起始位置其实是4421 * 4kb = 18108416,即0x0114 5000(哈哈,其实根本不用计算,我的问题,因为这里截断的本质其实是按照4kb进行页对齐) -
然后加上偏移量
0110 0111 1000(转换成十六进制是0x678,我们一般称这个偏移量为"页内偏移"),即最终地址是0x0114 5000 + 0x678 = 0x0114 5678 -
以上的这些步骤,是由一个叫做
MMU(Memory Management Unit,内存管理单元)的集成在CPU中的硬件电路完成的 -
MMU的电路中,包含一个叫做TLB(Translation Lookaside Buffer)的缓存,旨在提升查页表的速度,否则,在64位下查找一个四级页表,效率上还是比较低的 -
MMU在拿到物理地址并确定了权限无误后,会在主板总线上向内存发送这个地址,然后内存拿到物理地址地址后就会返回值给CPU
13.3.5 页表是程序在内存中运作起来的根基
13.3.5.1 空间申请
-
实际上,你在进程中动态申请的空间,一样需要保持页对齐,因为一个
4kb块是被管理的基本内存单位,所以使用一部分内存,最少也得需要申请一个块的大小 -
所以实际上,不管你在进程中动态申请多小的空间,内核都至少会分配一个
4kb块给你,而多余的无用空间,等你下次申请的时候,无需申请直接使用,当然这是内核/接口底层干的事情,这事我们无需太过于关注底层细节 -
而每申请一个块,就意味着页表树会新增一个叶子节点
-
当然,你申请内存的时候也不一定会直接分配给你块,而是要等到你要使用这部分空间的时候,才临时分配给你
13.3.5.2 缺页中断
-
我们知道页表中,每个项的低地址是标志位,会记录当前页表的使用情况,其中一种情况就是未开辟,意思是就是下级表都还没有申请到位,需要临时申请
-
当你试图使用一片内存时,此时内核会检查该地址处的块有没有真正申请下来,如果连映射关系都没有建立,此时
CPU就会触发缺页中断,即虚拟地址和物理地址有一部分对不上,然后保存进程上下文并进入内核态开始触发中断操作临时申请空间并分配给进程的对应虚拟地址,然后恢复进程上下文继续运行,检查无误才开始读/写 -
所以缺页中断其实是一种减少空间浪费的操作(包括页表自身空间,也包括申请的空间),同时,也能让用户放心大胆的申请空间,这也是为什么
vector这个容器会很放心地翻倍式申请空间的原因
13.3.5.3 ELF文件与页表的联系
-
我们知道,
ELF文件中存放着一整套进程启动的模板,或者说加载进内存并被OS管理的模板体系,其中在ELF文件的程序头表中就有一些字段,是用于帮助内核做页对齐的,换句话说,一个进程本身自己的只读的代码和数据加载进内存,其本身也是需要对齐到块的 -
而且这样做有一个好处,就是
ELF可以被不完全加载进内存,换句话说,可以只加载用到的页 -
如果某个未加载的内容突然要用,就会可能会触发缺页中断,然后临时加载进内存
-
换句话说,这也是一种节约内存空间的优化方式,且几乎所有程序都能享受到的来自设计者的优化方式
13.4 关于线程的概念
- 部分总结自[为什么单核处理器也需要多线程?]
13.4.0 场景
-
我们来谈一个场景
-
现在你运营了一个服务器,这个服务器上运行着一个服务端进程,这个服务端的工作很简单,即:
- 接受用户请求
- 加载资源到内存
- 发送资源给用户
-
其中,
CPU会工作的地方仅仅只有"接受用户请求"和"发送资源给用户" -
我们知道,
CPU的工作速度其实是非常快的,所以实际上,当一个用户发送完请求后,CPU会以极快的速度处理和分析完请求,然后等待资源加载到内存,然后再以极快的速度处理发送任务 -
所以,你会发现,在一整个任务流程中,其实
CPU在等待资源加载的过程中是在摸鱼的,换句话说就是此时CPU没活可干 -
假设此时服务端同时接收到了5个请求,那么因为执行流只有一条,所以
CPU得干完用户1的活,再干用户2的活,以此类推,但是干活过程中,因为执行流只有一条的原因,在数据传输完之前,该条执行流会被阻塞,所以实际上CPU在处理5个请求的过程中,摸了五次鱼,且因为磁盘做资源传输的效率比较慢,所以实际上CPU每次摸鱼时间都很长 -
这就导致用户拿到资源的速度太慢了
-
于是乎,你开始想办法增加执行流,于是,你想到可以用多进程实现多执行流,于是你做了一个操作,每当用户发送请求的时候,就让服务端创建一个子进程
-
于是,当你的服务端收到5个请求时,进程创建了5个子进程,当
CPU处理完一个进程的请求后,该进程会因为资源传输而阻塞,但因为是多进程,CPU还不至于没活干,当一个进程被阻塞住,CPU可以切换到有任务的进程 -
但实际上,这又会出现一个问题,你发现,一旦请求数量非常大,达到可能有几千个几万个的时候,此时你的服务器内存几乎就被撑爆了,同时,进程间切换/创建进程也会消耗一定的
CPU资源和时间,况且父进程和子进程的通信也会消耗时间不说,还非常难用,对于用户来说也会造成一定延迟,所以我们又迫切需要一种技术,帮助我们减少内存消耗,减少因为调度而造成的延迟 -
于是你想到用线程代替进程,线程轻量化,所以创建一个线程非常简单,不需要花费很多时间,同时线程也是独立的执行流,也可以被
CPU调度,所以不会有CPU摸鱼的问题,并且因为线程之间的资源是共享的,所以通信起来会非常方便,也无需其他的基于OS的共享资源就能实现线程通信 -
这里我们所举的这种例子,被称作"
IO密集型任务"
13.4.1 线程的软件层面优势
-
线程的引入,让"轻量化执行流调度"成为可能
-
线程不需要高昂且复杂的外部中间件实现通信,因为线程本身因为共享资源的特性,使得线程之间天生就可以互相通信
-
线程自身的资源没有进程那么多,线程仅包含一个执行流有的资源,使得线程的创建速度,调度/切换速度都比进程快很多,同时对于内存占用也低
-
所以对于
IO密集型任务而言,线程的使用是一个非常好的选择 -
而另一种场景,即计算密集型应用而言,线程也是非常好的选择
-
在很久之前,我们就提到过,进程池的本质是掠夺
CPU时间片,以达到加速让一个任务完成的目的 -
而进程的特性导致其会在创建和调度上都会慢一些,内存占用也会高一些
-
所以对于计算密集型而言,线程会是更好的选择
-
关于计算密集型任务,我们举个例子,你要计算
1~10000000000的阶乘,此时需要使用大量CPU资源,所以你可以通过池化技术掠夺CPU资源以加速完成任务,只需要将其分为多个子任务就行,这很好理解 -
思考:所以说,线程一定是越多越好吗?这个问题我们在线程的劣势中会做出解答
13.4.2 逻辑处理器与线程
-
IMB的员工发现,一个
CPU的核心在满载的时候,其实并不是所有资源都会用到,他可能只会用到一部分资源,其余的资源会保持空闲,如果能有办法将空闲资源利用到其他任务上,那么CPU的整体效率就会提高 -
于是,在1968年,IBM开始对
CPU的多线程技术进行研究 -
那么,
CPU多线程做了什么? -
简单来说,就是在一个传统意义的核心中,塞入了两个逻辑处理器
-
我们知道,一个核心其实不仅仅包括用于计算的单元,还有其他诸如缓存,寄存器等等其他资源
-
这里的两个逻辑处理器其实本质上就是在一个核心中又添加了一个用于计算的单元,即存在两个用于计算的单元,这样做的好处是,如果一个计算单元满载且还有其他资源空闲的时候,另一个核心可以拿去用
-
不过理想虽好,现实却是骨干的,因为如果这么做,就需要调度
CPU其他资源对于逻辑处理器的分配和冲突问题,所以实际上这么做仅仅带来了20%~30%的提升 -
所以本质上,逻辑处理器才是真正执行执行流的最小单元,我们在选购
CPU的时候,经常看到的类似于"6核12线程"这种,其中的"线程"就是只带的逻辑处理器的数量
13.4.3 线程的硬件层面优势
-
所以说,线程能在和进程一样运用多核心/多线程技术实现并行的情况下,还能够减少内存占用,提高调度效率
-
同时因为一个
CPU核心中,会对进程的部分资源进程缓存 -
如果使用多个进程的话,一旦切换进程,之前缓存的内容就全部作废了
-
而如果使用线程,那么之前缓存的内容依旧是可用的,这也是线程切换如此之快的原因之一
13.4.4 线程的劣势
-
虽然线程的优势很多,不过线程依旧也是有劣势的
-
比较典型的就是线程共享资源所造成的问题
-
我们知道,对于所有公共资源的写入操作,都需要保证原子性,所以对于公共资源来说,都需要加锁,否则会造成竞态,这也就造成了多线程的进程,代码写起来会复杂很多
-
除此之外,多个单线程的进程和多线程的进程都会存在一个问题
-
我们假设当前系统只有一个核心任务需要完成,且当前设备是多核心,的计算机
-
那么,如果只有一个执行流,那么就会造成一核有难多核围观的景象,换句话说就是只用到了一个逻辑处理器
-
如果我们添加执行流,就可以利用上空闲的
CPU -
但这个执行流并不是无脑就可以随便加的
-
执行流在这种情况下不能超过逻辑处理器上限,换句话说,一个逻辑处理器分配一个执行流,会让内存最大化合理利用
-
如果执行流超过上限,那么实际效率并没有提升,因为逻辑处理器就这么多,再怎么加执行流也没法提升效率了,同时还因为创建执行流造成了内存的浪费
-
不过值得一提的是,如果要和其他进程竞争
CPU资源,那么执行流超过上限并不见得一定是一件坏事,因为这可以让调度尽可能覆盖在此任务上,换句话说就是在"掠夺时间片"
13.4.5 线程切换与进程切换
-
在本小节中,我们来详细谈谈为什么线程切换的速度会比进程快很多
-
在此之前,我们得搞清楚线程的结构究竟有什么
-
直接输出结论,线程会独有这些东西:
- 寄存器
- 栈空间: 因为调用链不同,共享栈会导致调用链覆盖
- 线程ID
- 调度信息: 因为要区分执行流,所以一定得分开
task_struct- 信号掩码: 这意味着线程之间阻塞或者挂起的掩码是不互通的,毕竟已经是另一个执行流了
-
那么问题来了:我们曾经有学过,
task_struct中保存的内容可不是一点点,那么切换线程意味着一定要切换一堆东西,比方说用于描述文件的结构,真的是这样吗?? -
实际上不是的,同一个进程中的线程,其中
task_struct的很多内容都相同,并且最重要的一点,即task_struct中保存的很多东西,本身并不是其独占的,换句话说,其中保存的是指针而并不是实际内容,所以实际上是多个task_struct共享属性信息,切换同进程的线程的task_struct,关于进程的属性并不会切换 -
线程切换:
- 保存线程独占的部分寄存器中的内容(比方说栈地址,
task_struct这些) - 加载目标线程独占的寄存器的内容
- 跳转到新线程继续运行
- 保存线程独占的部分寄存器中的内容(比方说栈地址,
-
进程切换:
- 保存进程上下文
- 切换
task_struct - 切换
CR3指向的页表 - 刷新
TLB - 更新文件描述符表,信号表
- 加载目标进程的上下文内容
-
从本质上来说,进程切换效率没有线程切换高的原因是进程切换需要换的寄存器值太多太多了
13.5 线程的相关接口
13.5.1 接口使用
-
所有的与线程相关的接口的使用,都需要用到
pthread库,这是一个第三方的库,所以要加上-l,即加选项-lpthread,才能正常调用该库,同时还需要包含头文件<pthread.h> -
值得一提的是,
CPP11封装了该库,可以使用封装后的版本<thread>,使用方式我们后面会再谈到,值得一提的是,因为<thread>封装的是<pthread.h>的内容,所以如果要使用<thread>,依然要使用-lpthread才能使用 -
本小节只谈论
<pthread.h>内的常用接口 -
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void * (*start_routine)(void*), void *arg) -
该接口用于创建一个线程,具体参数解释如下:
thread:pthread_t是一个类型,表示一个线程的thread_t(后面我们会聊到如何查找一个线程),本质上是unsigned longattr: 用于设置线程的属性,要用默认属性的话使用NULL就行start_routine: 是一个函数指针,表示该执行流开始的函数arg: 表示start_routine函数传入的参数- 返回值: 成功的话会返回
0,如果不成功,则会返回错误码(并不是设置错误码,而是返回错误码,作为返回值返回的原因是其开销会更小)
-
pthread_t pthread_self(void) -
该接口用于获取当前线程的
ID,具体参数解释如下:- 返回值: 返回当前所处线程的
thread_t
- 返回值: 返回当前所处线程的
-
void pthread_exit(void *retval) -
该接口用于退出当前线程,具体参数解释如下:
retval: 这个其实是返回值,但这个返回值不能指向局部变量,因为线程是有独立的栈的,所以退出之后局部变量会销毁,这个指针必须指向一个不会销毁的变量,比方说在堆中的变量或者是全局变量
-
值得一提的是线程退出也可以用
return,只不过return后接的内容一样也是一个指向公共资源的指针 -
应一个值得一提的话题是,如果一个进程中只有一个线程,且此时这个线程调用了
pthread_exit(),那么此时的效果和直接调exit()差不多 -
int pthread_cancel(pthread_t thread) -
这个接口用于终止同一进程的其他线程,具体参数解释如下:
thread: 表示你要终止的线程的thread_t- 返回值: 如果为
0,则表示成功,不为零则表示返回了错误码
-
int pthread_join(pthread_t thread, void **retval) -
这个接口用于等待(获取)线程的退出信息(一旦调用这个接口,当前线程就会阻塞,直到其对应线程退出),一个线程退出之后,并不会在虚拟地址空间中立刻被删除,而是等待主线程获取完退出信息之后才会删除,具体参数解释如下:
thread: 表示要等待的线程IDretval: 表示指向返回值指针的指针- 返回值: 如果为
0,则表示成功,不为零则表示返回了错误码
-
值得注意的是
-
如果你不需要获取其返回值,只需要将
NULL传入retval -
一个线程如果不是主动退出的,而是被其他线程使用
pthread_cancel退出的,那么其退出信息是PTHREAD_ CANCELED,是一个常数 -
int pthread_detach(pthread_t thread) -
这个接口用于分离其他线程和主线程,具体参数解释如下:
thread: 需要分离的线程thread_t- 返回值: 如果为
0,则表示成功,不为零则表示返回了错误码
-
这个"分离"意思其实很好理解,一旦分离了,这个线程就可以自己释放了,意味着主线程不再关心该线程的退出信息了
-
任何线程都可以分离任何线程,这意味着自己分离自己也是可以的,但分离主线程是一个未定义的行文,不要去这么干
-
写个练习小程序
#include <cstdlib>
#include <iostream>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
pthread_t thread_3_id;
void* thread_3(void* arg)
{
(void)arg;
thread_3_id = pthread_self();
std::cout << "线程: " << pthread_self() << " 已运行" << std::endl;
while(true)
{}
return nullptr;
}
void* thread_2(void* arg)
{
(void)arg;
std::cout << "线程: " << pthread_self() << " 已运行" << std::endl;
sleep(2);
int ret = pthread_cancel(thread_3_id);
if(ret == 0)
{
std::cout << "已杀死: " << thread_3_id << std::endl;
}
else
{
std::cerr << "pthread_cancel_err: " << strerror(ret) << std::endl;
abort();
}
return nullptr;
}
void* thread_1(void* thread)
{
pthread_t tid;
int ret = pthread_create(&tid, nullptr, (void*(*)(void*))thread, nullptr);
if(ret != 0)
{
std::cerr << "pthread_create_err: " << strerror(ret) << std::endl;
abort();
}
else
{
std::cout << "创建线程: " << tid << std::endl;
}
std::cout << "当前线程用于创建并等待线程,当前线程ID为: " << pthread_self() << std::endl;
void* retval = nullptr;
pthread_join(tid, &retval);
if(retval == PTHREAD_CANCELED)
{
std::cout << "线程: " << tid << "被动退出" << std::endl;
}
else
{
std::cout << "线程: " << tid << "主动退出" << std::endl;
}
return nullptr;
}
int main()
{
pthread_t tid;
int ret_1 = pthread_create(&tid, nullptr, thread_1, (void*)thread_3);
if(ret_1 != 0)
{
std::cerr << "pthread_create_err: " << strerror(ret_1) << std::endl;
exit(ret_1);
}
int ret_2 = pthread_create(&tid, nullptr, thread_1, (void*)thread_2);
if(ret_2 != 0)
{
std::cerr << "pthread_create_err: " << strerror(ret_2) << std::endl;
exit(ret_2);
}
sleep(6);
return 0;
}
13.5.2 线程ID与进程ID与操作系统
13.5.2.1 查询线程
- 你可以通过
ps -aL来查询当前OS中存在的所有线程
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~$ ps -aL
PID LWP TTY TIME CMD
3663443 3663443 pts/0 00:00:00 exe_thread
3663443 3663444 pts/0 00:00:00 exe_thread
3663443 3663445 pts/0 00:00:00 exe_thread
3663443 3663446 pts/0 00:00:01 exe_thread
3663443 3663447 pts/0 00:00:00 exe_thread
-
这个
LWP(Light Weight Process,即轻量级进程)就是一个线程在OS中的唯一标识符,也就是ID -
当然你可以通过管道来过滤输出的内容
13.5.2.2 关联
-
我们在查询小节中看到的结果,是在执行上小节中写的示例的结果
-
我们来看看这个示例输出了什么内容
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_7_27_thread$ ./exe_thread
创建线程: 126810452920000
当前线程用于创建并等待线程,当前线程ID为: 126810473891520
创建线程: 126810442434240
当前线程用于创建并等待线程,当前线程ID为: 126810463405760
线程: 126810452920000 已运行
线程: 126810442434240 已运行
已杀死: 126810452920000
线程: 126810442434240主动退出
-
你发现一个问题了么,即:为什么通过
pthread_self得到的ID和在shell中通过ps查到的ID不一样? -
我们知道,在
Linux的视角存不存在线程这个东西?其实是不存在的,在Linux只存在轻量级进程,所以实际上你使用ps查到的其实是轻量级进程而非线程,而在用户视角是有线程的,所以要使用轻量级进程封装一个线程出来,所以说,其实pthread_self查到的ID,是线程内的线程唯一标识符,这是给用户看的,而在进程外,也就是在OS中,查到的永远是轻量级进程的唯一标识符
13.5.3 线程与进程与信号话题
-
那么问题来了,如果一个进程收到了一个信号,那么正在执行信号处理动作的时候,该进程的所有线程是否都要等待信号处理完毕才会继续执行?
-
答案是否定的
-
一个进程收到一个信号之后,只会挑选一个线程放下手头的任务,暂时转头执行信号处理动作,而其他线程不受影响,仍然可以并行/并发执行
13.5.4 线程与线程库
-
我们知道,线程是通过封装
LWP模拟出来的,那么调度逻辑其实全部都是执行的LWP的那一套 -
那么问题来了.既然用户空间中,我们看到的全都是叫做线程的东西,那么管理和描述线程的结构,究竟在哪里呢?
-
要搞清楚这个问题,我们要了解线程库的工作逻辑
-
线程库是一个动态库,意味着线程库会被所有映射了这个库的进程共享
-
线程库中定义了一个结构,即
struct pthread,在操作系统学科中的名字叫做TCB,就如同task_struct和PCB的关系 -
struct pthread通常包含以下成员:- 线程
ID,即TID(这个就是使用ps查出来的那个LWP) - 描述栈的信息
errno- 信号掩码
join相关的结构- 调度策略
- 锁相关的内容
- 线程局部存储(
TLS,概念后面会提到) - 返回值
- 等等
- 线程
-
另一个有意思的话题,
pthread_self查出来的pthread_t为什么这么长?原因其实也很简单,因为pthread_t其实就是struct pthread的起始地址,或者说在用户空间中属于线程部分的起始地址 -
我们知道,动态库会链接到共享区,会有若干个进程可以共享使用动态库
-
那么问题来了,我们知道,
struct pthread的定义存在动态库中,那么创建struct pthread本身是否创建在动态库的空间中呢?如果是,既然动态库是被共享的,那么是不是其他进程可以访问到当前进程的所有线程呢? -
我们知道,一个
ELF程序被分为好几个段,其中有.text,即代码段,.data,即数据段 -
动态库本身就是一个
ELF程序,所以该有的都有 -
那么以上问题,换个说法就是:使用动态库
.text中的代码,创建的数据,会写到动态库自己的.data里吗? -
答案是否定的
-
并不会写道自己的
.data中,意味着,其他进程无法看到/访问到当前进程创建的线程 -
那么这是怎样做到的?
struct pthread究竟放在了哪里呢? -
一旦调用
pthread_create()这个接口,该接口就会做以下几件事情:- 使用
mmap()申请一片独立的空间,我们称为"匿名映射段"("匿名映射段"指仅分配物理页映射进虚拟地址空间中,而找不到任何文件,换句话说,就是线程不需要加在任何文件进来) - 在"匿名映射段"中开辟
struct pthread的空间并初始化,设置初始值 - 开辟独属于线程自己的栈空间
- 开辟独属于线程自己的局部存储(
TLS)
- 使用
-
其实简单来说就是开辟了一片独属于线程自己的空间并映射到进程的虚拟地址空间中,和其他绝大部分区域隔绝开
-
这个
TLS是什么玩意,干嘛用的? -
对于线程来讲,其实不能将所有的东西都和进程进行绑定,比方说调度相关的全局变量,
errno这种全局变量,这些其实是要独立开的 -
所以专门为线程划了一片空间用于存放这些需要独立开的全局变量,并且拷贝进程对应的部分过来,对于线程自己来说,看到的其实永远都是拷贝的副本而已,一旦这些值被修改,其实也影响不到其他线程
-
如果你想要对一个变量做局部存储修饰,可以在变量前加上
__thread
__thread int a = 1;
-
不过得注意,这个只能用于修饰内置类型或者部分指针
-
值得一提的是,虽然我们说这是线程独属的空间,但实际上这是直接映射到共享区的,换句话说就是在共享区划空间分给线程
-
所以,总的来说,关于线程的创建问题,本质上是动态库提供代码,实际开辟却在进程自己的空间中
-
当然,这只是用户空间对于线程的视角
-
关于
mmap()这里做一个补充,这个接口很通用,不仅是一个共享内存的接口,可以用于进程间通信,还可以用于申请大块内存私用 -
本质上就是申请某个指定的且固定大小的虚拟地址或者是由接口分配虚拟地址,然后需要使用时会触发缺页中断并申请物理页临时做映射
-
可以设置权限,包括私有还是可共享,读写权限等等
-
并且还可以将文件加载进内存,并映射到虚拟地址实现文件的共享
-
并且,因为
mmap()这个接口的初衷需要满足共享内存或者文件的用途,所以开辟的空间都在共享区而不是堆区 -
又并且,因为开辟的空间都是固定的,所以除了主线程之外,其他的所有线程的栈空间全都没法增长,因为其开辟的空间是固定的
13.5.5 用户空间的线程和操作系统的轻量级进程的联动问题
-
那么另一个有意思的问题,用户空间的线程和操作系统的
LWP之间,是怎样联动的??? -
在了解这个问题之前,我们得先知道,如何创建一个轻量级进程
-
int clone(int (*fn)(void *_Nullable), void *stack, int flags, void *_Nullable arg, ... /* pid_t *_Nullable parent_tid, void *_Nullable tls, pid_t *_Nullable child_tid */ ) -
这个接口就是用于创建一个轻量级进程的
-
我们仔细观察一下这个接口的参数:
fn: 这就是一个回调函数,用于告诉轻量级进程从什么位置开始执行stack: 是栈的起始地址,用于告诉轻量级进程,你的栈的位置在哪里flags: 一个位掩码,表示是否和创建它的轻量级进程共享某个位置的资源arg: 需要传入回调函数的参数
-
所以,换句话说,就是
pthread_create()先开辟了线程的空间,然后调用了clone(),将开辟的空间作为轻量级进程的空间使用,在用户视角就是创建了一个线程 -
至于调度的事情,就是操作系统调度
LWP了,线程是啥操作系统不知道
13.5.6 封装线程
- 这里我们写个样例,封装一下相关接口
- 值得一提的是,在这个样例中,并没有写很多锁相关的内容,意味着这个样例的代码多半是不安全的,不要使用,后面我们了解完加锁机制之后会再该这部分代码的
// test_thread.cpp
// 这个测试文件中,我选择创建两个线程,一个线程用于测试接口,另一个线程用于检测用于测试接口的线程的状态
// 因为没有写加锁相关的内容,不需要测试锁功能,所以这里用sleep()还是比较合理的
#include "mythread.h"
#include <pthread.h>
#include <iostream>
void* func(void* arg)
{
while(true)
{
std::cout << "我是线程:" << pthread_self() << std::endl;
std::cout << "arg为: " << *(static_cast<int*>(arg)) << std::endl;
sleep(1);
}
int* result = new int(*(static_cast<int*>(arg)) * 2);
return result;
}
void print_status(oldking::thread* t)
{
std::cout << t->get_name() << "的状态是: ";
if(t->get_stat() == oldking::THREAD_STATUS::NEW_THREAD)
std::cout << "NEW_THREAD" << std::endl;
if(t->get_stat() == oldking::THREAD_STATUS::RUNNING_THREAD)
std::cout << "RUNNING_THREAD" << std::endl;
if(t->get_stat() == oldking::THREAD_STATUS::STOP_THREAD)
std::cout << "STOP_THREAD" << std::endl;
if(t->get_stat() == oldking::THREAD_STATUS::KILLED_THREAD)
std::cout << "KILLED_THREAD" << std::endl;
}
void* monitor(void* pthread)
{
oldking::thread* real_pthread = static_cast<oldking::thread*>(pthread);
while(true)
{
print_status(real_pthread);
// 这个函数可以用于设置取消点,本质上,另一个线程如果cancel当前线程,实际上是发送了一个cancel请求,而当前线程并不会立刻取消,而是会在某个时机检测有没有线程发送cancel请求,我们称检测的时机为取消点,而这个函数唯一的功能就是用来检测cancel请求的,除此之外,sleep,read,write等等接口都会检测cancel,所以一般情况下,不需要用这个接口,除非你的线程中的函数几乎没有/功能过于单一,才会用这个
pthread_testcancel();
sleep(1);
}
return nullptr;
}
int main()
{
int* num = new int(1);
oldking::thread t1("thread_1", func, static_cast<void*>(num));
oldking::thread* pthread = &t1;
oldking::thread monitor_thread("monitor_thread", monitor, static_cast<void*>(pthread));
monitor_thread.run();
sleep(3);
t1.run();
sleep(3);
t1.stop();
sleep(3);
t1.join();
sleep(3);
monitor_thread.stop();
sleep(3);
monitor_thread.join();
sleep(3);
}
// mythread.h
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
#include <functional>
#include <string>
#include <atomic>
namespace oldking
{
// 这是强枚举,区别于普通枚举,强枚举中的值必须要指定命名空间使用,而普通枚举的值是暴露的,这样做不安全,我们在后面的代码中会实际见到
enum class THREAD_STATUS
{
NEW_THREAD,
RUNNING_THREAD,
STOP_THREAD,
KILLED_THREAD
};
// 对于状态的封装
struct thread_status
{
// 加了std::atomic<THREAD_STATUS>之后,所有对stat的操作都会变成原子的,可以保护stat,这个玩意我估计还会再见到
std::atomic<THREAD_STATUS> stat;
};
// 如果要封装线程库的接口,一个比较尴尬的点是,线程库只支持函数指针的传入,而在CPP中这样做显然是不太合适的,所以我们可以玩一个设计
// 我们设计一个入口函数,以后我们传进pthread_create的函数就是这个入口函数,但实际的业务逻辑并不在入口函数中,而是在入口函数的参数中
// 我们知道,我们可以自定义传入该函数的参数的类型,所以,我们只需要将业务逻辑函数和业务逻辑函数的参数封装进一个结构中,new完后,转为(void*)传进入口函数就行
// 实际在设计这个结构的时候,主要的相关成员有:
// 1. 线程当前的状态的指针(这集这个的原因是,线程有权利改变当前的状态,而不是必须要等待主线程cancel,意味着线程即可以选择自行退出,主线程也可以cancel该线程,所以我们需要将线程的状态也传递给线程自己,以便它可以该自己的状态,虽然它能改变的只有runing->stop就是了)
// 2. 一个function,用于承载实际的业务逻辑
// 3. 一个void*指针的read_arg,用于承载实际需要传入业务逻辑的参数
// 那么设计完这个结构之后,你在实际使用这个结构的过程中会发现一个很有意思的问题,即,我们在该结构中设计的成员,在封装thread的结构中也会存在,且全部存在,所以,与其说使用这个结构,不如直接使用thread自己来得更方便,因为使用自己,不需要new,传指针的时候,传this就行了
// struct thread_arg
// {
// thread_arg(thread_status* pstat, std::function<void*(void*)>& func, void* real_arg)
// : _pstat(pstat)
// , _func(func)
// , _real_arg(real_arg)
// {}
//
// thread_status* _pstat;
// std::function<void*(void*)> _func;
// void* _real_arg;
// };
// 封装线程相关接口的结构
class thread
{
public:
// 入口函数
static void* entry(void* arg)
{
thread_status* pstat = (static_cast<thread*>(arg))->_pstat;
std::function<void*(void*)> func = (static_cast<thread*>(arg))->_func;
void* ret = func((static_cast<thread*>(arg))->_real_arg);
pstat->stat = oldking::THREAD_STATUS::STOP_THREAD;
return ret;
}
public:
// 构造函数
thread(const std::string& name, const std::function<void*(void*)>& func, void* arg)
: _pstat(new thread_status) // 这里new是因为stat是共享的,所以需要new
, _name(name)
, _func(func)
, _real_arg(arg)
, _id(0)
{
_pstat->stat = THREAD_STATUS::NEW_THREAD;
}
std::string get_name()
{
return _name;
}
THREAD_STATUS get_stat()
{
return _pstat->stat;
}
// 运行线程
bool run()
{
if(_pstat->stat != THREAD_STATUS::NEW_THREAD)
return false;
int ret = pthread_create(&_id, nullptr, entry, static_cast<void*>(this));
if(ret != 0)
{
std::cerr << "pthread_create err: " << strerror(ret) << std::endl;
exit(ret);
}
_pstat->stat = THREAD_STATUS::RUNNING_THREAD;
return true;
// pthread_detach(_id);
}
// 在主线程中强制关闭该线程
bool stop()
{
if(_pstat->stat != THREAD_STATUS::RUNNING_THREAD)
return false;
int ret = pthread_cancel(_id);
if(ret != 0)
{
std::cerr << "pthread_cancel err: " << strerror(ret) << std::endl;
exit(ret);
}
_pstat->stat = THREAD_STATUS::STOP_THREAD;
return true;
}
// 等待线程,获取退出信息
void* join()
{
if(_pstat->stat != THREAD_STATUS::STOP_THREAD && _pstat->stat != THREAD_STATUS::RUNNING_THREAD)
return nullptr;
void* result;
int ret = pthread_join(_id, &result);
if(ret != 0)
{
std::cerr << "pthread_join err: " << strerror(ret) << std::endl;
exit(ret);
}
_pstat->stat = THREAD_STATUS::KILLED_THREAD;
return result;
}
// 析构
~thread()
{
if(_pstat->stat == THREAD_STATUS::RUNNING_THREAD)
{
stop();
join();
}
else if(_pstat->stat == THREAD_STATUS::STOP_THREAD)
{
join();
}
delete _pstat;
return ;
}
private:
thread_status* _pstat;
std::string _name;
std::function<void*(void*)> _func;
void* _real_arg;
pthread_t _id;
};
}
- 改进建议(挖坑):
- 使用
pthread_setname_np()设置线程名字 - 使用模板让线程参数自定义
- 实现线程自定义参数数量
- 使用
13.6 互斥锁
13.6.1 数据不一致问题
-
其实我们在信号部分就了解过数据不一致问题了
-
我们来看一个例子
#include <iostream>
#include <pthread.h>
#include <unistd.h>
int a = 0;
void* func(void* arg)
{
(void)arg;
int n = 100000;
while(n--)
{
a++;
}
return nullptr;
}
int main()
{
pthread_t t1;
pthread_t t2;
pthread_t t3;
pthread_t t4;
pthread_create(&t1, nullptr, func, nullptr);
pthread_create(&t2, nullptr, func, nullptr);
pthread_create(&t3, nullptr, func, nullptr);
pthread_create(&t4, nullptr, func, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
std::cout << a << std::endl;
return 0;
}
- 如果我们跑一下这段代码,初看可能没有问题,但如果多跑几次的话...
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
400000
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
400000
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
300000
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
339690
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
400000
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
400000
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
400000
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
338480
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
400000
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_8_1_race_condition$ ./exe
- 一个非常恐怖的事情发生了,这还能有结果不一样的?!
- 原因很简单,我们看一张图

-
你发现了吗,因为实际处理的时候,是以并行状态执行,所以会有多个执行流处理计算
-
但实际上,
++这个步骤并不是一个原子的,它存在一个中间状态 -
比方说线程1在写入公共资源也就是整形
1到寄存器之后,因为公共资源并没有改变,此时如果线程2也写入公共资源到寄存器,此时计算完写回之后,线程1写回的是1,线程2写回的也是1,这不就等于没算吗?!!! -
是这样的!我们称这种为竞态条件,即多个线程竞争式的读取或者写入内容到公共资源,造成的数据不一致问题
-
所以说,我们需要一些操作,将这个公共资源保护起来
-
加锁是一种方式

-
但你发现了么,这似乎存在一个问题,即如果线程2因为加锁阻塞了,那不就和单线程一样了吗?!一核有难,八核围观的问题就出现了!
-
是的没错,加锁一定是会损耗部分效率的,所以不能无脑加锁
-
关于加锁效率问题,我们在后面会详细谈谈
-
当然,除了算术运算之外,逻辑运算也是一个大坑,逻辑运算虽然本身不改变值,但逻辑运算返回的布尔值可能会导致执行逻辑改变,最终导致结果不符合预期,所以,在对公共资源做逻辑运算前,也需要加锁,这点很重要
-
另一个问题,多线程不仅仅在并行状态下可能会有这种问题,在并发也可能会有这种问题
-
比方说在
CPU写回数据之前,线程被阻塞,调度,中断了!此时有别的线程计算完并写回,此时也一样会造成数据不一致问题
13.6.2 互斥锁的相关接口与使用
13.6.2.1 互斥锁的使用
- 申请锁一般有两种方式
- 一种一般用作全局的锁,叫做静态初始化的锁,一种用作局部的锁,叫做动态初始化的锁
- 全局的锁不需要手动释放,进程和结束之后会自动被释放
- 而局部锁一般作为保护某个对象的成员变量所用,锁本身也是该对象的成员变量,该对象调用构造的时候需要手动申请该锁,该对象调用析构的时候,需要手动释放该锁
pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER; // 全局锁一般用这个值进行初始化
// int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
// mutex: 需要申请的锁
// attr: 属性,一般是nullptr
// int pthread_mutex_destroy(pthread_mutex_t *mutex);
// mutex: 需要销毁的锁
class mymutex
{
public:
mymutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
~mymutex()
{
pthread_mutex_destroy(&_mutex);
}
private:
pthread_mutex_t _mutex;
};
// 加锁
int pthread_mutex_lock(pthread_mutex_t *mutex); // 不成功会阻塞,直到成功,出错返回errno
// 试图加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex); // 不成功会返回errno,成功返回0
// 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); // 不成功会返回errno,成功返回0
13.6.2.2 实操&封装一个锁&实现简单的线程安全日志类
// test.cpp
#include <pthread.h>
#include <stdlib.h>
#include "mythread.hpp"
#include "myeasylog.hpp"
#include "mutex.hpp"
oldking::my_easy_log mylog("./log.txt");
oldking::mymutex mutex;
void* func(void* arg)
{
// std::cout << "new thread!" << std::endl;
srandom(time(nullptr));
(void)arg;
while(true)
{
int n = random() % 3;
oldking::log_level_t level;
if(n == 0)
level = SIMP_LEVEL;
else if(n == 1)
level = WARN_LEVEL;
else
level = ERR_LEVEL;
mutex.Lock();
mylog.Write("None", level);
mutex.Unlock();
}
}
int main()
{
// int n = 10;
mylog.LoadFile();
oldking::thread t1("thread_1", func, nullptr);
t1.run();
oldking::thread t2("thread_2", func, nullptr);
t2.run();
oldking::thread t3("thread_3", func, nullptr);
t3.run();
oldking::thread t4("thread_4", func, nullptr);
t4.run();
oldking::thread t5("thread_5", func, nullptr);
t5.run();
oldking::thread t6("thread_6", func, nullptr);
t6.run();
oldking::thread t7("thread_7", func, nullptr);
t7.run();
oldking::thread t8("thread_8", func, nullptr);
t8.run();
oldking::thread t9("thread_9", func, nullptr);
t9.run();
while(true)
{}
return 0;
}
// myeasylog.hpp
#pragma once
#include <iostream>
#include <cstring>
#include <string>
#include <stdio.h>
#include <time.h>
namespace oldking
{
typedef std::string log_level_t;
#define SIMP_LEVEL "SIMPLE"
#define WARN_LEVEL "WARNNING"
#define ERR_LEVEL "ERROR"
class my_easy_log
{
public:
// my_easy_log(const int& max_line, const std::string& file)
my_easy_log(const std::string& file)
// _max_line(max_line)
: _linecnt(0)
, _plogfile(nullptr)
, _file(file)
{}
bool LoadFile()
{
FILE* tmp = fopen(_file.c_str(), "a+");
if(tmp != nullptr)
{
std::cout << "LoadFile!" <<std::endl;
_plogfile = tmp;
return true;
}
else
{
std::cerr << "fopen err: " << strerror(errno) << std::endl;
return false;
}
}
void Write(const std::string& message, const log_level_t& level)
{
// std::cout << "Write!" << std::endl;
if(_plogfile == nullptr)
{
std::cerr << "_plogfile is nullptr" << std::endl;
return ;
}
time_t now = time(nullptr);
struct tm* tm_info = localtime(&now);
char buffer[64];
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", tm_info);
fprintf(_plogfile, "time: %s , level: %s , message: %s\n", buffer, level.c_str(), message.c_str());
_linecnt++;
}
// void SetMax(const unsigned long long& max_number)
// {
// _max_line = max_number;
// }
unsigned long long GetLineCount()
{
return _linecnt;
}
~my_easy_log()
{
if(_plogfile == nullptr) return ;
fclose(_plogfile);
}
private:
// const unsigned long long _max_line;
unsigned long long _linecnt;
FILE* _plogfile;
std::string _file;
};
}
// mythread.hpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
#include <functional>
#include <string>
#include <atomic>
namespace oldking
{
enum class THREAD_STATUS
{
NEW_THREAD,
RUNNING_THREAD,
STOP_THREAD,
KILLED_THREAD
};
struct thread_status
{
std::atomic<THREAD_STATUS> stat;
};
// struct thread_arg
// {
// thread_arg(thread_status* pstat, std::function<void*(void*)>& func, void* real_arg)
// : _pstat(pstat)
// , _func(func)
// , _real_arg(real_arg)
// {}
//
// thread_status* _pstat;
// std::function<void*(void*)> _func;
// void* _real_arg;
// };
class thread
{
public:
static void* entry(void* arg)
{
thread_status* pstat = (static_cast<thread*>(arg))->_pstat;
std::function<void*(void*)> func = (static_cast<thread*>(arg))->_func;
void* ret = func((static_cast<thread*>(arg))->_real_arg);
pstat->stat = oldking::THREAD_STATUS::STOP_THREAD;
return ret;
}
public:
thread(const std::string& name, const std::function<void*(void*)>& func, void* arg)
: _pstat(new thread_status)
, _name(name)
, _func(func)
, _real_arg(arg)
, _id(0)
{
_pstat->stat = THREAD_STATUS::NEW_THREAD;
}
std::string get_name()
{
return _name;
}
THREAD_STATUS get_stat()
{
return _pstat->stat;
}
bool run()
{
if(_pstat->stat != THREAD_STATUS::NEW_THREAD)
return false;
int ret = pthread_create(&_id, nullptr, entry, static_cast<void*>(this));
if(ret != 0)
{
std::cerr << "pthread_create err: " << strerror(ret) << std::endl;
exit(ret);
}
_pstat->stat = THREAD_STATUS::RUNNING_THREAD;
return true;
// pthread_detach(_id);
}
bool stop()
{
if(_pstat->stat != THREAD_STATUS::RUNNING_THREAD)
return false;
int ret = pthread_cancel(_id);
if(ret != 0)
{
std::cerr << "pthread_cancel err: " << strerror(ret) << std::endl;
exit(ret);
}
_pstat->stat = THREAD_STATUS::STOP_THREAD;
return true;
}
void* join()
{
if(_pstat->stat != THREAD_STATUS::STOP_THREAD && _pstat->stat != THREAD_STATUS::RUNNING_THREAD)
return nullptr;
void* result;
int ret = pthread_join(_id, &result);
if(ret != 0)
{
std::cerr << "pthread_join err: " << strerror(ret) << std::endl;
exit(ret);
}
_pstat->stat = THREAD_STATUS::KILLED_THREAD;
return result;
}
~thread()
{
if(_pstat->stat == THREAD_STATUS::RUNNING_THREAD)
{
stop();
join();
}
else if(_pstat->stat == THREAD_STATUS::STOP_THREAD)
{
join();
}
delete _pstat;
return ;
}
private:
thread_status* _pstat;
std::string _name;
std::function<void*(void*)> _func;
void* _real_arg;
pthread_t _id;
};
}
// mutex.hpp
#pragma once
#include <cstdio>
#include <cstring>
#include <pthread.h>
#include <iostream>
namespace oldking
{
class mymutex
{
public:
mymutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
void Lock()
{
int tmp_errno;
if((tmp_errno = pthread_mutex_lock(&_mutex)) != 0)
{
std::cerr << "pthread_mutex_lock err: " << strerror(tmp_errno) << std::endl;
}
}
void Unlock()
{
int tmp_errno;
if((tmp_errno = pthread_mutex_unlock(&_mutex)) != 0)
{
std::cerr << "pthread_mutex_lock err: " << strerror(tmp_errno) << std::endl;
}
}
~mymutex()
{
pthread_mutex_destroy(&_mutex);
}
private:
pthread_mutex_t _mutex;
};
}
13.6.2.3 死锁问题
- 搞清楚什么是死锁之前,我们先来看看这个例子
#include <iostream>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER;
void* func1(void* arg)
{
(void)arg;
while(true)
{
usleep(100);
pthread_mutex_lock(&mutex1);
pthread_mutex_lock(&mutex2);
std::cout << "我是线程: " << pthread_self() << std::endl;
pthread_mutex_unlock(&mutex1);
pthread_mutex_unlock(&mutex2);
}
return nullptr;
}
void* func2(void* arg)
{
(void)arg;
while(true)
{
usleep(100);
pthread_mutex_lock(&mutex2);
pthread_mutex_lock(&mutex1);
std::cout << "我是线程: " << pthread_self() << std::endl;
pthread_mutex_unlock(&mutex2);
pthread_mutex_unlock(&mutex1);
}
return nullptr;
}
int main()
{
pthread_t t1;
pthread_create(&t1, nullptr, func1, nullptr);
pthread_t t2;
pthread_create(&t2, nullptr, func2, nullptr);
while(true) ;
return 0;
}
-
在这个例子中,锁有两把,一个锁1,一个锁2
-
如果你实际运行这个程序,你会发现这个程序跑了一段时间之后,可能就直接卡住不动了
-
我们来深入看看造成这种现象的原因
-
原因出在这两个代码
pthread_mutex_lock(&mutex1);
pthread_mutex_lock(&mutex2);
pthread_mutex_lock(&mutex2);
pthread_mutex_lock(&mutex1);
-
我们知道,一个线程如果试图给一个已经锁住的锁上锁,那么这个线程会阻塞,直到这个锁被解锁
-
在这两个进程的这两个代码中,存在一种少见的情况,即线程1给锁1上锁之后,线程2立刻将锁2上锁,此时线程1试图将锁2上锁,结果发现锁2已经被锁住了,于是线程1陷入阻塞,线程2此时试图将锁1上锁没结果发现锁1已经被锁住了,于是线程2也陷入阻塞,于是两个线程都进入了阻塞,且两个锁都无法被解锁,这就造成了线程死锁问题
-
如何解决这个问题?
-
即做类似于"锁的顺序绑定"的东西(自己起的名字hhh)
-
准确来说,就是保证在一个锁能被上锁,不会阻塞的情况下,保证其他所有的锁都可以被上锁
-
换句话说,只需要调整一下上锁顺序就行
void* func1(void* arg)
{
(void)arg;
while(true)
{
usleep(100);
pthread_mutex_lock(&mutex2);
pthread_mutex_lock(&mutex1);
std::cout << "我是线程: " << pthread_self() << std::endl;
pthread_mutex_unlock(&mutex1);
pthread_mutex_unlock(&mutex2);
}
return nullptr;
}
void* func2(void* arg)
{
(void)arg;
while(true)
{
usleep(100);
pthread_mutex_lock(&mutex2);
pthread_mutex_lock(&mutex1);
std::cout << "我是线程: " << pthread_self() << std::endl;
pthread_mutex_unlock(&mutex2);
pthread_mutex_unlock(&mutex1);
}
return nullptr;
}
- 比方说上头这样,锁2一旦被上锁,那么其他所有线程都没法再次将锁2上锁,所以也就不会试图上锁锁1,也就规避了死锁问题
- 但实际上,我认为这种方式仅适用于两个锁用在临近代码的情况,因为实际情况可能会更加复杂
- 我们知道,互斥锁不仅仅有全局锁,还有局部锁,局部锁一般是用于保护一个局部资源的安全,然后多半封装在一个对象里
- 那么问题就来了,在没有提供文档,没有提供源码的情况下,你不知道也无法知道这个对象里有用局部锁
- 哪怕是提供了源码,也有可能因为其底层奇怪的调用链,导致与对象外部的代码冲突
- 比方说在对象内,加锁的顺序可能是"锁1->锁2->锁3"
- 而在对象外的其他代码中,则有可能是"锁1->锁3->锁2",于是就有死锁风险(这点也一定是适用于局部锁的,因为哪怕是局部锁,只要这个对象是公共资源,那么锁对于线程来说也是共享的)
- 所以,这种纯调转加锁顺序的方式只能作为特例了解,实际情况下,这个方法肯定没这么实用,鲁棒性差
- 于是我们可以用另一种方法
void* func1(void* arg)
{
(void)arg;
while(true)
{
usleep(100);
while(true)
{
// 这个接口不会阻塞,所以就不会造成死锁
// 使用轮询,就可以既保证等待解锁,又保证不会死锁
if(pthread_mutex_trylock(&mutex1) == 0)
{
if(pthread_mutex_trylock(&mutex2) == 0)
{
std::cout << "我是线程: " << pthread_self() << std::endl;
pthread_mutex_unlock(&mutex1);
pthread_mutex_unlock(&mutex2);
break;
}
else
{
pthread_mutex_unlock(&mutex1);
}
}
}
}
return nullptr;
}
void* func2(void* arg)
{
(void)arg;
while(true)
{
usleep(100);
while(true)
{
if(pthread_mutex_trylock(&mutex1) == 0)
{
if(pthread_mutex_trylock(&mutex2) == 0)
{
std::cout << "我是线程: " << pthread_self() << std::endl;
pthread_mutex_unlock(&mutex1);
pthread_mutex_unlock(&mutex2);
break;
}
else
{
pthread_mutex_unlock(&mutex1);
}
}
}
}
return nullptr;
}
-
这是一种退让策略,即如果某个锁申请成功,下一个锁我也得尝试申请,不强行申请,如果下一个锁不能做到申请成功,我得把之前的所有锁全部释放了,防止一直占用锁导致死锁,同时还要
unsleep()一下挂起线程,防止线程因为while(true)一直占用CPU -
对此,如果是每一层的调用链中都有锁,我们就必须在设计对象的时候就要考虑清楚,仅暴露一个类似于
try_do的接口出来,以方便做出退让
while (true)
{
if (pthread_mutex_trylock(&my_lock) == 0)
{
// 这个resource是有锁的,try_do方法会trylock,对象的局部锁加锁失败立刻返回,防止外部锁占用
if (resource.try_do())
{
pthread_mutex_unlock(&my_lock);
break;
}
// try_do失败必须释放所有外部锁,防止锁占用
pthread_mutex_unlock(&my_lock);
}
// 短暂挂起,防止死锁,这一个操作必须要做,否则会导致当前线程一直占用逻辑处理器导致"活锁",或者说防止其他线程饥饿
usleep(100);
}
- 另一种常见的死锁情况是递归调用导致的死锁
void* func(void* arg)
{
(void)arg;
pthread_mutex_lock(&mutex1);
std::cout << "我是线程: " << pthread_self() << std::endl;
func(nullptr);
pthread_mutex_unlock(&mutex1);
return nullptr;
}
- 这种也可以通过
trylock保证其安全性,或者直接从根源上,不使用递归,而使用迭代
13.6.2.4 细粒度锁与粗粒度锁的问题
-
概念:
- 细粒度: 一把锁的临界区域小
- 粗粒度: 一把锁的临界区域大
-
换句话说,就是细粒度能锁住的代码比较少,可能也就针对某个公共资源加锁
-
而粗粒度可能会对多个公共资源甚至是局部资源加锁
-
细粒度和粗粒度各有优劣
-
细粒度:
- 优势: 线程处在串行的时间很短,所以细粒度的线程效率很高,资源竞争会变少
- 劣势: 因为锁很多,所以对于锁本身的管理是一个很麻烦的事情,同时还面临着死锁的问题
-
粗粒度:
- 优势: 一个锁能锁住很多的公共资源,所以锁很少,死锁很难发生,也很好管理
- 劣势: 因为管理太多公共资源,所以常常会发生资源竞争,效率会变低(串行效率太低)
13.6.3 加锁与解锁本身也是原子的
- 我们知道,对于所有的线程而言,锁本身也是一个变量,也是公共资源(临界资源),所以势必的,加锁/解锁这种改变锁的状态的操作也必须是原子的
13.6.4 锁与线程切换/中断与锁的本质
-
值得注意的是,加锁的线程,并不是一直不会被调度,也不是一直不会被中断,本质上内核是完全不知道有锁这种东西存在的
-
所以,哪怕是加锁的线程,一就可以被切换/中断
-
那么,这么做不会有其他的,等待解锁的线程开始获取时间片并向后执行代码吗?
-
答案是,不会
-
本质上,
Linux内核只提供了原子操作,没有提供任何的"锁" -
所有的锁,都是一种状态,而对锁的操作,都是一套"标准",这个标准的内容,即在什么场景,怎样使用
Linux提供的原子操作 -
加锁的线程,不会因为调度而被唤醒,同时,因为一个进程中,可能会存在多个不同的锁,所以等待不同锁的线程,一定会被分配在不同的等待队列中
-
那么,这还是解释不清楚,"为什么不会有其他的,等待解锁的线程开始获取时间片并向后执行代码"的问题
-
因为,我们知道,等待锁的线程,都会放进内核级别的等待队列中,难道,对锁的操作中,还能访问到内核的队列吗?
-
答案是,是的!锁,可以有办法控制内核的等待队列!!!!
-
内核,提供了一整套对于调度的系统调用,包括但不限于:
- "当前线程将自己阻塞挂起"
- "创建队列"
- "删除队列"
- "对队列做
push操作" - "对队列做
pop"操作 - 等等
-
当一个线程,试图加锁的时候,会检测锁的状态,如果锁被占用,那么它会将自己阻塞挂起
-
当一个线程解锁的时候,会修改锁的状态,同时检测等待队列,如果队列中还有元素,就将其
pop,并让其获得锁(即锁指向该线程) -
所以,站在线程库开发者的角度,其实锁本质就是被模拟出来的!!
13.7 条件变量
13.7.1 什么是条件变量
- 简单来说,条件变量的推出用于解决
CPU的忙等问题 - 什么是"忙等"?
- 类似于进程间通信的管道
- 比方说父进程正在往一个共享文件中写入内容,在写入内容这个操作结束之前,子进程不能做任何操作,只能不断通过
while()判断父进程有没有写完 - 放在线程中,也是一样的道理,比方说主线程正在往一个队列中
push()值,此时其他线程就只能不断while()判断主线程有没有写完,并始终占用CPU,这种情况就是"忙等" - 实际上这对于线程而言是极为低效的,因为
while()这个东西没有做任何实质性的操作,仅判断,而且还会占用CPU资源 - 所以我们希望有一种机制,在某个共享资源条件不满足时,保持其他线程阻塞挂起,直到这个贡献资源条件满足
- 老实说我觉得这就类似于一个唤醒机制一样
13.7.2 条件变量的使用
-
同样的,
pthread库也为我们提供了条件变量等配套对象和接口 -
和互斥锁一样,条件变量也有静态初始化和动态初始化之分,分别用于申请全局的条件变量和局部的条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 静态初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr); // 动态初始化
- 使用方式也很简单
int pthread_cond_signal(pthread_cond_t *cond);
// 当前线程可以用这个接口通知其他正在等待该条件变量通知的线程,并选取一个进行唤醒并开始执行任务
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
// 一旦某个线程运行到这个接口,那么该线程会存在两种情况阻塞在这里
// 其一,没有其他线程通过cond通知该线程
// 其二,有其他线程通过cond通知该线程但mutex已经被其他线程加锁了,此时该线程会阻塞等待mutex,直到mutex解锁
// 一旦这个接口处不在阻塞并继续运行,则会将mutex自动加锁,所以使用完这个接口记得手动解锁
int pthread_cond_broadcast(pthread_cond_t *cond);
// 区别于pthread_cond_signal(),这个接口会将所有等待cond的线程全部唤醒
13.7.3 实操
- 这是一个经典的生产者-消费者模型
- 即生产者线程不断生产任务,让消费者帮忙处理
- 当然,这是一个非常基础的版本,实际上我会在后面完善这个模型,其中还有很多机制需要做修改
#include <iostream>
#include <pthread.h>
#include <string>
#include <string.h>
#include <queue>
#include <time.h>
#include <stdlib.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
std::queue<std::string> q;
void* thread1(void* args)
{
(void)args;
while(true)
{
pthread_cond_wait(&cond, &mutex); // 等待通知
while(q.size()) // 对队列进行重新检查,如果仍符合条件则继续处理
{
std::cout << "线程1: " << pthread_self() << " 接收到通知,开始处理" << std::endl;
std::string front_str = q.front();
q.pop();
pthread_mutex_unlock(&mutex);
std::cout << "处理了: " << front_str << std::endl;
int time = 2;
while(time--)
{
(void)args; // 模拟线程内其他任务
usleep(10000);
}
pthread_mutex_lock(&mutex); // 重新加锁,配合重新检查流程
}
pthread_mutex_unlock(&mutex); // 队列不符合条件,不再进行处理,并再次循环等待通知
}
return nullptr;
}
void* thread2(void* args)
{
(void)args;
while(true)
{
pthread_cond_wait(&cond, &mutex);
while(q.size())
{
std::cout << "线程2: " << pthread_self() << " 接收到通知,开始处理" << std::endl;
std::string front_str = q.front();
q.pop();
pthread_mutex_unlock(&mutex);
std::cout << "处理了: " << front_str << std::endl;
int time = 2;
while(time--)
{
(void)args; // 模拟线程内其他任务
usleep(10000);
}
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
}
return nullptr;
}
int main()
{
std::vector<std::string> str_list = {"oldking is H", "gugugaga", "oooooohouhouhou~", "666", "nailong", "114514", "yihahaha", "wuhu"};
srandom(time(nullptr));
// 以上代码用于随机获取一个既定的字符串
pthread_t t1;
pthread_create(&t1, nullptr, thread1, nullptr);
pthread_t t2;
pthread_create(&t2, nullptr, thread2, nullptr);
int time = 10;
while(time--)
{
// 这部分代码用处从str_list中随机选取str插入
int cnt = random() % 10;
while(cnt--)
{
pthread_mutex_lock(&mutex);
q.push(str_list[random() % str_list.size()]);
pthread_mutex_unlock(&mutex);
}
if(q.size() > 2)
{
std::cout << "队列少于两个str" << std::endl;
pthread_cond_broadcast(&cond);
// 如果队列中数量比较多,则可以考虑全部唤醒
}
else if(q.size() == 0)
{
continue;
}
else
{
std::cout << "队列多于两个str" << std::endl;
pthread_cond_signal(&cond);
// 如果队列中数量比较少,可以减少线程唤醒数量,比方说这里就唤醒了一个线程
}
sleep(2);
}
return 0;
}
13.7.4 条件变量的一些问题和解决办法
13.7.4.1 效率低下问题
-
条件变量需要使用互斥锁机制,那么势必也会继承加锁的负面特性
-
如果一个操作系统中,除了内核之外几乎不存在其他进程,且当前进程中只存在一个公共资源允许被其他线程访问,且线程除了处理该公共资源,没有其他任务,那么此时如果唤醒全部线程,这些线程就会竞争该资源,最终会坍缩成单线程的效率,甚至不如单线程
-
那么解决方案也含简单
- 线程要有自己局部的任务,对于全局资源不会改变任务逻辑的任务,可以将全局访问的资源拷贝到局部,直接避免加锁造成的竞争
- 为不同的线程分配不同类型的任务,个事个办,互不打扰,减少锁的使用,也减少竞争
- 将公共资源做区分,一批公共资源分成好几个公共资源,分别加锁并分配给线程,减少竞争
-
所以本质上,多线程其实不是让多个线程处理同一个任务,而是让多线程处理多任务,不论是相同类型的任务也好,还是完全不相同的任务也好
13.7.4.2 线程饥饿
-
另一个条件变量容易出现的问题是,如果一个线程因为公共资源长期被加锁,或者是线程长期没有被通知到,此时就也有可能会造成无限阻塞,我们称为线程饥饿
-
解决方案也很简单
-
我们可以使用一个对象记录所有线程的使用状态,比方说根据线程的活动频率,推算出一个线程是否经常饥饿,然后用两种方式将经常饥饿的线程杀死,要么是主线程将饥饿线程杀死,要么是通过伪广播唤醒,让线程自己判断自己是否该退出
-
当然,还可以为其做定制化机制,比方说某个时间段可能会成为高并发的时间段,该时间点用户可能会集中访问,那么就可以将空的线程先预留,暂时不做删除
-
同时,杀死机制也可以做定制化,如果主线程自身任务处理繁忙,就可以允许线程自杀,反之亦然,当然,还是要根据实际情况实际处理
13.7.5 根据情况,防止出现数据不一致问题
- 尽量不要使用全局变量
- 不读只写的算术运算这种特殊情况可以最后才写回
- 对于内置类型可以使用
atomic保护(不过,这个atomic还有些出入,似乎没这么简单,我需要再研究下),减少lock和unlock次数,防止一核有难多核围观 lock和unlock
13.8 日志类
- 当然,其实我自己实操线程这部分的时候,实际上是先写线程池,再写日志类的,主要是不清楚多线程的调试,所以我选择从日志下手,不过我们先从日志开始聊
18.8.1 什么是日志类?
-
日志类本质是一个用于输出日志的对象
-
程序员可以在一些关键的位置,比方说用户操作接口位置,可疑的栈溢出位置,错误处理中,自定义信号处理方法中等等地方输出日志,就像是我们学习
C的时候,有时候可能会直接用printf打印某个值的情况 -
只不过
printf有些时候不够强大,我们需要一个能够处理更加复杂信息的方式检测程序运行情况
18.8.2 日志类需要什么?
- 首先,我们可以想想,一个日志类究竟需要一些什么功能?
- 若干种信息类型:
- 时间
- 日志等级:
DEBUG: 用于DEBUG的消息INFO: 常规消息,比方说用户操作内容,账户金额变化等等WARNING: 可能会造成错误的消息ERROR: 已经造成错误,或者表示用户未定义操作等等FATAL: 不可处理错误,表示执行了不可恢复的操作,程序将强制退出
- 所处文件名
- 进程
ID - 线程
ID - 行号
- 日志信息
- 信息处理: 日志类需要自动获取一部分信息,比方说时间,进程/线程
ID,行号,等,以减少日志接口的复杂程度 - 多线程优化: 要求允许多线程访问日志类,要求做线程安全
- 向显示器输出日志
- 向特定文件输出日志
- 要求在未来可以很方便地扩展输出形式,比方说输出到网络,输出到内存文件等等
- 只允许当前进程拥有一个日志类: 日志类作为一个日志输出的中枢,一个进程可以将所有线程/功能的日志统一输出到一个文件中,或者是屏幕中(不过也可以不这么设计,也可以设计成允许不同模块独立拥有日志类,做到独立输出日志,不过这是后话了,我们今天不这么设计)
- 若干种信息类型:
18.8.3 单例模式
18.8.3.1 什么是单例模式
- 单例模式是设计模式中的一种,设计模式我们暂时可以粗浅的看成写某个功能需要用到的方式,以实现高质量,符合规范,和业务紧密挂钩的代码
- 简单来说,单例模式就是强制让某个对象只允许在该进程中存在一份,不允许存在多份,且必须是全局的
- 比方说某些程序的全局配置/设置
- 或者更加贴切的例子,我们玩游戏的时候改的键位一定得在整个程序中全局生效,且不能存在多个键位表,只允许存在一个键位表,否则可能会造成某些地方改键失效等等问题
18.8.3.2 如何实现单例模式
- 我们先来看看"斯科特·迈耶斯单例",斯科特·迈耶斯是著名的《Effective C++》的作者
- 值得注意的是这种方式仅限于
C++11或更高版本
class example
{
private:
// 单例模式不允许外部构造,只允许内部构造
example() = default;
example(const example& other) = delete;
example& operator=(const example& other) = delete;
public:
~example() = default;
// 这里可以实现其他方法
// 访问单例模式类的唯一接口,同时也是单次创建单例模式类的唯一接口
static example& GetInstance()
{
static example instance;
return instance;
}
//在这个例子中,这个example类不能被外部构造,但内部构造却可以实现,因为巧用了peivate机制实现允许内部构造但不允许外部构造的机制
//然后在其内部实现一个GetInstance方法,这个方法中,会构造一个instance对象,这个对象是全局的,静态的,但如果不调用Get方法,就不能初始化这个对象
//但规定了,如果要使用这个对象,就只能先调用Get方法,然后通过其返回的引用使用该对象
//于是就实现了该静态对象的强制初始化
//如果再次调用Get方法试图使用instance时,因为已经被初始化过一次了,所以之后的代码中,用于初始化的那一行就会被忽略/不执行
//同时,静态对象在CPP11中,其初始化是线程安全的,所以不用担心线程安全问题
private:
// 这里可以定义其他成员
};
- 如果需要在比
C++11更低版本的情况下编译的话,则需要使用一个叫做"双重检查锁定"的方式
#include <pthread.h>
class singleton
{
private:
singleton() = default;
singleton(const singleton& other) = delete;
singleton& operator=(const singleton& other) = delete;
public:
~singleton() = default;
public:
volatile singleton* GetInstance()
{
// 这也是一个很巧妙的例子
// 首先我们需要知道的是,单例模式只允许一个线程初始化一个唯一的实例
// 而要保证单线程不重复创建,首先就需要用if判断是否已经存在实例
// 如果不存在,就需要初始化
// 此时我们还得保证,不能有两个线程同时初始化实例
// 比方说两个线程同时完成了第一次实例存在的检查了
// 于是都开始着手创建工作,此时就重复创建了
// 所以需要加锁,且锁本身也得是唯一的
// 如果两个线程竞争锁
// 那么竞争到锁的线程会先做后续执行,进入if并初始化实例然后解锁
// 没竞争到锁的线程会等待解锁后做后续执行,但因为有第二次if判断,此时一定已经存在实例了,所以不会进入if
// 于是就解锁并返回了
// 同时,因为实例初始化后一定不用再加锁了,所以第一个if同时也保证了不会再有线程访问这个锁了,也就防止了不必要的锁竞争
if(singleton_pointer == nullptr)
{
pthread_mutex_lock(&mutex);
if(singleton_pointer == nullptr)
singleton_pointer = new singleton;
pthread_mutex_unlock(&mutex);
}
return singleton_pointer;
}
private:
// 有些情况下可能会先分配内存并返回指针,然后在执行构造函数,所以我们要加一个volatile防止出现指向一个未初始化空间的问题
static volatile singleton* singleton_pointer;
static pthread_mutex_t mutex;
};
// 初始化静态成员
volatile singleton* singleton::singleton_pointer = nullptr;
pthread_mutex_t singleton::mutex = PTHREAD_MUTEX_INITIALIZER;
18.8.4 策略模式
18.8.4.1 什么是策略模式
-
同样的,策略模式也是设计模式中的一种
-
策略模式通过将某个模块中需要新增功能的地方抽象成"策略",减少测试成本,并提升扩展性
-
或者更简单的话说,就是写对外开放的插件,功能通过调整插件而调整
-
现在我们深究一下
-
我们知道,通过继承,我们可以让一个"抽象方法"适配不同的功能
-
比方说我们这里的日志
-
我们可以搞一个
LogOutputStrategy的基类,这个类是一个抽象类,其中规范了日志的输出功能需要有的基本内容 -
后续的策略类可以继承这个基类,比方说
FileOutput,CmdOutput可以继承自LogOutputStrategy,但输出细节不同 -
实际在日志类中,只需要定义一个抽象类的"策略",具体执行什么策略取决于用户的设置(或者说具体执行什么策略取决于抽象类中输出方法是被哪个派生类的同名输出方法重写了)
-
后续如果要新增新的策略,就可以直接继承
LogOutputStrategy,采用统一的接口规范,同时之前写过的代码还不用做更改(比方说日志类本身不需要修改,也不用改动其他策略)
18.8.4.2 如何实现策略模式
- 具体请看日志类的实现
18.8.5 日志类的实现
#pragma once
// =======================INCLUDE=========================
#include <iostream>
#include <cstring>
#include <string>
#include <stdio.h>
#include <ctime>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <queue>
#include <memory>
#include <list>
#include "mutex.hpp"
namespace oldking
{
// =======================TYPEDEF=========================
// type in log
typedef std::string log_level_t;
typedef std::string log_time_t;
typedef std::string log_filename_t;
typedef std::string log_process_id_t;
typedef std::string log_thread_id_t;
typedef std::string log_line_num_t;
typedef std::string log_message_t;
// =======================DEFINE==========================
// log level for my_easy_log
#define LOG_DEBUG "DEBUG"
#define LOG_INFO "INFO"
#define LOG_WARNING "WARNING"
#define LOG_ERROR "ERROR"
#define LOG_FATAL "FATAL"
// default log file path
#define LOG_FILE "./log.txt"
// =======================BASE CLASSES====================
// 基础日志类,用于构造单行日志
struct BaseLog
{
//BaseLog(const log_level_t& level, const log_filename_t& filename, const log_message_t& message)
//: _time(GetTime())
//, _level(level)
//, _filename(filename)
//, _pid(GetProcessID())
//, _tid(GetThreadID())
//, _line("NONE")
//, _message(message)
//{}
BaseLog() = default;
~BaseLog() = default;
BaseLog(const BaseLog& other) = default;
BaseLog& operator=(const BaseLog& other) = default;
// 初始化 | 覆写
void Init(const log_level_t& level, const log_filename_t& filename, const log_message_t& message)
{
_get_time_mutex.Lock();
_time = GetTime();
_get_time_mutex.Unlock();
_level = level;
_filename = filename;
_pid = GetProcessID();
_tid = GetThreadID();
_line = "NONE";
_message = message;
_time.pop_back();
_a_log = "[time]:" + _time +
" [level]:" + _level +
" [filename]:" + _filename +
" [pid]:" + _pid +
" [tid]:" + _tid +
" [line]:" + _line +
" [message]:" + _message;
}
void clear()
{
_time = "";
_level = "";
_filename = "";
_pid = "";
_tid = "";
_line = "";
_message = "";
_a_log = "";
}
// 移动构造
BaseLog(BaseLog&& other)
{
swap(other);
}
// 移动赋值
BaseLog& operator=(BaseLog&& other)
{
swap(other);
return *this;
}
std::string& GetLog()
{
return _a_log;
}
private:
static log_time_t GetTime()
{
time_t time_buff = time(nullptr);
struct tm* tm_info = localtime(&time_buff);
char buffer[1024];
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", tm_info);
return buffer;
//return log_time_t(ctime(&time_buff));
}
static log_process_id_t GetProcessID()
{
return std::to_string(static_cast<int>(getpid()));
}
static log_thread_id_t GetThreadID()
{
return std::to_string(static_cast<unsigned long long>(pthread_self()));
}
void swap(BaseLog& other)
{
std::swap(_time, other._time);
std::swap(_level, other._level);
std::swap(_filename, other._filename);
std::swap(_pid, other._pid);
std::swap(_tid, other._tid);
std::swap(_line, other._line);
std::swap(_message, other._message);
}
private:
log_time_t _time;
log_level_t _level;
log_filename_t _filename;
log_process_id_t _pid;
log_thread_id_t _tid;
log_line_num_t _line;
log_message_t _message;
std::string _a_log;
oldking::mymutex _get_time_mutex;
};
// 策略基类 -- 用于规范后续设计的所有输出策略
class LogOutputStrategy
{
public:
LogOutputStrategy() = default;
virtual ~LogOutputStrategy()
{}
virtual void Write(BaseLog* base_log_p,
const log_level_t& level,
const log_filename_t& filename,
const log_message_t& message) = 0;
};
// 派生类 -- 日志文件输出模式
class FileOutput : public LogOutputStrategy
{
public:
FileOutput(std::string file = LOG_FILE)
: _file(file)
{
LoadFile();
}
~FileOutput() override
{
CloseFile();
}
void Write(BaseLog* base_log_p,
const log_level_t& level,
const log_filename_t& filename,
const log_message_t& message) override
{
// init base_log
base_log_p->Init(level, filename, message);
// output to the file
_file_mutex.Lock();
fprintf(_plogfile, "%s \n", base_log_p->GetLog().c_str());
fflush(_plogfile);
_file_mutex.Unlock();
}
private:
// 文件加载
void LoadFile()
{
FILE* tmp = fopen(_file.c_str(), "a+");
if(tmp != nullptr)
{
// std::cout << "LoadFile!" <<std::endl;
_plogfile = tmp;
return;
}
else
{
std::cerr << "fopen err: " << strerror(errno) << std::endl;
exit(errno);
}
}
// 文件释放
void CloseFile()
{
if(fclose(_plogfile) != 0)
{
std::cerr << "fclose err: " << strerror(errno) << std::endl;
exit(errno);
}
}
private:
FILE* _plogfile;
std::string _file;
oldking::mymutex _file_mutex;
};
// 派生类 -- 显示器输出模式
class CmdOutput : public LogOutputStrategy
{
public:
CmdOutput() = default;
~CmdOutput() override
{}
void Write(BaseLog* base_log_p,
const log_level_t& level,
const log_filename_t& filename,
const log_message_t& message) override
{
// init base_log
base_log_p->Init(level, filename, message);
// output to the file
_cmd_mutex.Lock();
std::cout << base_log_p->GetLog() << std::endl;
_cmd_mutex.Unlock();
}
private:
oldking::mymutex _cmd_mutex;
};
// =======================LOG CLASS=======================
class MyEasyLog
{
private:
// 单例模式不允许外部构造,只允许内部构造
MyEasyLog(size_t size)
{
_q_mutex.Lock();
for(size_t i = 0; i < size; i++)
{
_void_log_q.push(new BaseLog);
}
_q_mutex.Unlock();
SetOutputLogtoFile();
}
MyEasyLog(const MyEasyLog& other) = delete;
MyEasyLog& operator=(const MyEasyLog& other) = delete;
public:
~MyEasyLog()
{
_q_mutex.Lock();
while(_void_log_q.size())
{
delete _void_log_q.front();
_void_log_q.pop();
}
_q_mutex.Unlock();
}
// 外部的日志输出接口
void WriteLog(const log_level_t& level,
const log_filename_t& filename,
const log_message_t& message)
{
// get base log
_q_mutex.Lock();
BaseLog* baselog_p = nullptr;
while(true)
{
if((baselog_p = _void_log_q.front()) != nullptr)
{
break;
}
else
{
_q_mutex.Unlock();
usleep(1000);
_q_mutex.Lock();
}
}
_void_log_q.pop();
_q_mutex.Unlock();
// output
_file_mutex.Lock();
_p_writer->Write(baselog_p, level, filename, message);
_file_mutex.Unlock();
// return base log to the queue
_q_mutex.Lock();
_void_log_q.push(baselog_p);
_q_mutex.Unlock();
}
// 显示器输出模式
void SetOutputLogtoCmd()
{
// make_unique会帮我们new
// 其本质是帮我们new完之后,返回一个右值指针
// 然后智能指针通过移动赋值将新资源交换进来
// 旧的资源会被make_unique释放
_p_writer = std::make_unique<CmdOutput>();
}
// 日志文件输出模式
void SetOutputLogtoFile()
{
_p_writer = std::make_unique<FileOutput>();
}
// 访问单例模式日志类的唯一接口,同时也是单词创建单例模式日志类的唯一接口
static MyEasyLog& GetInstance()
{
static MyEasyLog myeasylog_instance(5);
return myeasylog_instance;
}
//在这个例子中,这个logger类不能被外部构造,但内部构造却可以实现,因为巧用了peivate机制实现允许内部构造但不允许外部构造的机制
//然后在其内部实现一个GetInstance方法,这个方法中,会构造一个instance对象,这个对象是全局的,静态的,但如果不调用Get方法,就不能初始化这个对象
//但规定了,如果要使用这个对象,就只能先调用Get方法,然后通过其返回的引用使用该对象
//于是就实现了该静态对象的强制初始化
//如果再次调用Get方法试图使用instance时,因为已经被初始化过一次了,所以之后的代码中,用于初始化的那一行就会被忽略/不执行
//同时,静态对象在CPP11中,其初始化是线程安全的,所以不用担心线程安全问题
private:
std::queue<BaseLog*, std::list<BaseLog*>> _void_log_q;
oldking::mymutex _q_mutex;
oldking::mymutex _file_mutex;
// 智能指针用于帮助我们自动释放资源
// 这里用智能指针包了一个基类,我们通过在派生类重写基类方法,实现了非常高的可扩展性
// 以及尽可能保证输出方法和日志类解耦
// 这里我们用了策略模式,将输出方式实现为策略类
// 且全部的策略全部继承自基础策略抽象类(用于规范策略类需要重写的接口)
// 在日志类中,我们不关心使用怎样的输出策略
// 而在策略类中,除了baselog之外,我们也不关心其他任何日志类的成员,仅做输出
// 实现了一种弱解耦的状态
// 是开闭原则的非常好的体现(开闭原则指高扩展性,却不可修改类的内部实现)
// 将代码测试工作变得更为简单,我们只需要测试新增加的策略,原来的类就不用测试了
std::unique_ptr<LogOutputStrategy> _p_writer;
};
}
18.8.6 什么地方适合使用日志类?
-
并不是所有的细枝末节都需要使用日志类
-
如果所有的地方都需要使用日志类,那么可能会导致
log文件异常大,且不好观察 -
所以,一般情况下只有一些关键逻辑需要输出日志,比方说主线程的部分逻辑
-
日志只是帮我们知晓大体问题发生的位置/监控程序大体走向
-
所以日志只能看作是大纲,而不能是具体步骤
-
具体步骤的审查,还得需要看源码和调试抑或是
printf
18.9 线程池
18.9.1 线程池设计
- 线程池的大体设计是这样的
- 我们设计一个任务队列,这个任务队列中存放着任务方法以及任务参数,同时存在一个任务队列锁用于保护任务队列,防止出现线程安全问题
- 同时,我们还通过
list管理各个线程 - 线程自身的实现应该是与线程池的实现相耦合的,线程有其固定一套的循环逻辑用于从"等待任务"与"执行任务"之间切换
- 最初的设计还是比较简单一点,因此我比较草率地默认让线程自己
detach,否则还得维护线程自身是否detach的状态 - 我这里的核心宗旨,是让线程所有操作全部"任务化",甚至说线程自己退出也任务化,线程检测该任务是某个特殊的任务,就会自动实现操作
- 当然我们也可以在线程中实现一个引用或者指针,指向线程池的一个特殊的只读任务表格,该表格中记录所有的特殊任务,用于给线程发布特殊任务,甚至是
detach,stop,start等等,当然我这里并没有这么做
18.9.2 线程池实现
// mythread.hpp
#pragma once
#include <pthread.h>
#include <atomic>
#include <functional>
#include <queue>
#include <tuple>
#include "myeasylog.hpp"
namespace oldking
{
typedef int THREAD_STAT;
#define KILLED_TD 0
#define WAIT_TD 1
#define RUNNING_TD 2
class MyThreadforThreadPool
{
private:
// 交换函数,用于移动
void _swap(MyThreadforThreadPool& other) noexcept
{
std::swap(_ppqueue, other._ppqueue);
std::swap(_ppcond, other._ppcond);
std::swap(_ppqmutex, other._ppqmutex);
std::swap(_stat, other._stat);
std::swap(_tid, other._tid);
}
public:
// 不允许任何拷贝
// 默认规范是不允许线程对象做任何拷贝动作,因为如果允许,那么用户不知道有没有新建线程
MyThreadforThreadPool(const MyThreadforThreadPool& other) = delete;
MyThreadforThreadPool& operator=(const MyThreadforThreadPool& other) = delete;
// 允许移动构造
MyThreadforThreadPool(MyThreadforThreadPool&& other) noexcept
: _tid(0)
, _stat(nullptr)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "begin to move copy");
_swap(other);
if(_stat == nullptr)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_WARNING, "mythread.hpp", "_stat is a nullptr");
}
}
// 允许移动赋值
MyThreadforThreadPool& operator=(MyThreadforThreadPool&& other) noexcept
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "begin to move operator=");
_swap(other);
if(_stat == nullptr)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_WARNING, "mythread.hpp", "_stat is a nullptr");
}
return *this;
}
// 构造
MyThreadforThreadPool(pthread_cond_t* ppcond, pthread_mutex_t* ppqmutex, std::queue<std::tuple<std::function<void*(void*)>, void*>>* ppqueue)
: _ppqueue(ppqueue)
, _ppcond(ppcond)
, _ppqmutex(ppqmutex)
, _stat(new std::atomic<THREAD_STAT>)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "begin to constructor");
if(_stat == nullptr)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_WARNING, "mythread.hpp", "_stat is a nullptr");
}
pthread_create(&_tid, nullptr, entry, static_cast<void*>(this));
}
// 析构
~MyThreadforThreadPool()
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "begin to destructor");
if(_stat != nullptr)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_WARNING, "mythread.hpp", "delete the _stat!");
if(_tid == 0)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_ERROR, "mythread.hpp", "delete the other thread's _stat!");
}
delete _stat;
}
}
// 用于给线程获取自身的相关属性
std::atomic<THREAD_STAT>* GetStatPointer()
{
return _stat;
}
pthread_cond_t* GetCondPointer()
{
return _ppcond;
}
pthread_mutex_t* GetMutexPointer()
{
return _ppqmutex;
}
pthread_t GetthreadID()
{
return _tid;
}
std::queue<std::tuple<std::function<void*(void*)>, void*>>* GetQueuePointer()
{
return _ppqueue;
}
// 修改线程状态
void ChangeStat(THREAD_STAT stat)
{
// thread safe
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "change stat");
if(_stat == nullptr)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_FATAL, "mythread.hpp", "the pstat is a nullptr!");
}
*_stat = stat;
}
private:
// 入口函数
static void* entry(void* entry_arg)
{
// get info
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "entry:: get info");
MyThreadforThreadPool* pthis = static_cast<MyThreadforThreadPool*>(entry_arg);
pthread_mutex_t* ppqmutex = pthis->GetMutexPointer();
pthread_cond_t* ppcond = pthis->GetCondPointer();
std::queue<std::tuple<std::function<void*(void*)>, void*>>* ppqueue = pthis->GetQueuePointer();
const pthread_t pid = pthis->GetthreadID();
// detach
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "entry:: detach");
pthread_detach(pid);
// change stat to waitting
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "entry:: change stat to waitting");
pthis->ChangeStat(WAIT_TD);
while(true)
{
// wait & lock for task_queue
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "entry:: wait & lock for task_queue");
pthread_mutex_lock(ppqmutex);
while(ppqueue->empty())
{
pthread_cond_wait(ppcond, ppqmutex);
}
// change stat to running
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "entry:: change stat to running");
pthis->ChangeStat(RUNNING_TD);
// get task & arg
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "entry:: get task & arg");
std::function<void*(void*)> task = std::get<0>(ppqueue->front());
void* parg = std::get<1>(ppqueue->front());
// pop task from task queue
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "entry:: pop task from task queue");
ppqueue->pop();
// unlock
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "entry unlock");
pthread_mutex_unlock(ppqmutex);
if(!task)
break;
void* ret_val = task(parg);
(void)ret_val;
// save return value
// ...
}
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread.hpp", "entry:: thread return");
pthis->ChangeStat(KILLED_TD);
return nullptr;
}
std::queue<std::tuple<std::function<void*(void*)>, void*>>* _ppqueue;
pthread_cond_t* _ppcond;
pthread_mutex_t* _ppqmutex;
pthread_t _tid;
std::atomic<THREAD_STAT>* _stat;
};
}
// mythread_pool.hpp
#pragma once
#include "mythread.hpp"
#include <queue>
#include <functional>
#include <tuple>
#include <list>
#include <pthread.h>
#include "mutex.hpp"
#include "myeasylog.hpp"
namespace oldking
{
// 几个常犯的错误
// 1. 别忘记detach/手动join
// 2. 移动语义一定不能试图释放公共资源!
class MyThreadPool
{
public:
MyThreadPool(const size_t& cnt)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread_pool", "begin to constructor");
pthread_mutex_init(&_queue_mutex, nullptr);
pthread_cond_init(&_task_cond, nullptr);
for(size_t i = 0; i < cnt; i++)
{
_td_list_mutex.Lock();
_thread_list.emplace_back(&_task_cond, &_queue_mutex, &_task_queue);
_td_list_mutex.Unlock();
}
}
void KillAll()
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread_pool", "begin to KillAll");
std::function<void*(void*)> ExitTask;
pthread_mutex_lock(&_queue_mutex);
for(size_t n = 0; n < _thread_list.size(); n++)
{
// 相当于发送退出任务,并唤醒所有线程接收该任务
_task_queue.push(std::tuple<std::function<void*(void*)>, void*>(ExitTask, nullptr));
}
pthread_cond_broadcast(&_task_cond);
pthread_mutex_unlock(&_queue_mutex);
}
size_t GetThreadCnt()
{
return _thread_list.size();
}
void SendTask(std::function<void*(void*)> task,
void* arg)
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread_pool", "SendTask:: begin");
if(GetThreadCnt() < 1)
return ;
pthread_mutex_lock(&_queue_mutex);
// 这里直接用中文写,因为这个点非常牛逼
// 其一,如果我们使用push,实际上会多一层tuple的拷贝
// 其二,使用move还可以最大化使用task,能直接剽窃task
// 最后值得注意的是,如果外部填的是一个lambda之类的临时变量的话,直接用引用会报错,要么用const修饰+引用的组合,但这样又会造成另一个恶心的问题,即queue只允许传入不带const的function(我觉得应该修正这个问题),实际传参传不进去,像这样就非常合适,并且这样还可以减少tuple的拷贝
_task_queue.emplace(std::move(task), arg);
pthread_cond_signal(&_task_cond);
pthread_mutex_unlock(&_queue_mutex);
}
// 清理已经销毁线程的线程对象
size_t ClearTrash()
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread_pool", "ClearTrash:: begin");
_td_list_mutex.Lock();
size_t cnt = 0;
for(auto it = _thread_list.begin(); it != _thread_list.end(); )
{
if(*(it->GetStatPointer()) == KILLED_TD)
it = _thread_list.erase(it); // 注意迭代器失效问题
else
it++;
_td_list_mutex.Unlock();
cnt++;
}
return cnt;
}
~MyThreadPool()
{
oldking::MyEasyLog::GetInstance().WriteLog(LOG_INFO, "mythread_pool", "begin to destructor");
KillAll();
sleep(1);
pthread_mutex_destroy(&_queue_mutex);
pthread_cond_destroy(&_task_cond);
}
private:
std::queue<std::tuple<std::function<void*(void*)>, void*>> _task_queue;
pthread_cond_t _task_cond;
pthread_mutex_t _queue_mutex;
oldking::mymutex _td_list_mutex;
// 这里请务必使用list,因为线程本身涉及指针,线程会有资源指向维护线程状态的对象,而vector亦或是deque之类的容器可能会有扩容操作,扩容会导致资源存储位置发生变动,会造成线程中指向维护线程状态的对象的指针悬空,所以我们使用扩容不改变资源位置的list来作为存放线程对象的容器
// 一定要切记,C++中,容器的资源一旦涉及到指针,就必须做好指针悬空的准备
std::list<oldking::MyThreadforThreadPool> _thread_list;
// _stat_manager
};
}
// mutex.hpp
#pragma once
#include <cstdio>
#include <cstring>
#include <pthread.h>
#include <iostream>
namespace oldking
{
class mymutex
{
public:
mymutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
void Lock()
{
int tmp_errno;
if((tmp_errno = pthread_mutex_lock(&_mutex)) != 0)
{
std::cerr << "pthread_mutex_lock err: " << strerror(tmp_errno) << std::endl;
}
}
void Unlock()
{
int tmp_errno;
if((tmp_errno = pthread_mutex_unlock(&_mutex)) != 0)
{
std::cerr << "pthread_mutex_lock err: " << strerror(tmp_errno) << std::endl;
}
}
~mymutex()
{
pthread_mutex_destroy(&_mutex);
}
private:
pthread_mutex_t _mutex;
};
}
18.10 其他线程安全问题
18.10.1 STL容器与线程安全问题
- 默认情况下,
STL容器会优先保证效率而非安全,所以你能见到STL容器总是可能造成指针悬空等等问题,同时,对于线程安全,SLT容器也是没办法做保证的 - 所以默认情况下,程序员需要自己加锁对
STL容器做保护,STL本身不会对资源做保护
18.10.2 智能指针与线程安全问题
-
本质上智能指针也并不是线程安全的,或者说任何智能指针指向的资源都不是线程安全的,智能指针本身不负责线程安全问题
-
比方说
unique_ptr,这个智能指针只允许存在一个该指针指向资源,所以不允许拷贝,但允许移动 -
另一个
shared_ptr,这个就允许共享指向的资源,内部是靠引用计数实现的 -
对于这两个指针来说,其指向的资源一定都是需要程序员自己做线程安全保护的
-
但
shared_ptr内部用于引用计数的成员,这个成员是原子的,所以新增一个shared_ptr共享资源本质上也是原子的,所以不用担心新增shared_ptr会造成线程安全问题
- 如有问题或者有想分享的见解欢迎留言讨论