线程概念
有些情况需要在一个进程中同时执行多个控制流程,这时候线程就派上了用场,比如实现一个图形界面的下载软件,一方面需要和用户交互,等待和处理用户的鼠标键盘事件,另一方面又需要同时下载多个文件,等待和处理从多个网络主机发来的数据,这些任务都需要一个“等待-处理”的循环,可以用多线程实现,一个线程专门负责与用户交互,另外几个线程每个线程负责和一个网络主机通信。
Linux当中是没有线程的概念的,而是将其称作轻量级进程:LWP,通俗的线程概念其实是C库(libc.so.6)当中的概念。
在一个程序当中只有一个主线程个多个工作线程。(其他被创建出来的线程)
主线程:轻量级进程——>struct task struct{...};
pid_t pid;
pid_t tgid;
pid==tgid
工作线程:轻量级进程——>struct task_struct{...};
pid_t pid;
pid_t tgid;
tgid和主线程当中的tgid是一样的,但还是pid是不一样的。
由于同一进程的多个线程共享同一地址空间,因此TextSegment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
但有些资源是每个线程各有一份的:
- 线程id
- 上下文,包括各种寄存器的值、程序计数器和栈指针
- 栈空间
- errno变量
- 信号屏蔽字
- 调度优先级
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
要使用这些函数库,要通过引入头文<pthread.h>
链接这些线程函数库时要使用编译器命令的“-lpthread”选项
线程创建
#include <pthread.h>
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,void *(*start_routine)(void*), void *arg);
- thread:线程标识符,是一个出参
- pthread_attr_t:线程属性
- 一般不设置线程的属性,传值为NULL,采用默认属性
- start_routine:本质是函数指针,保存线程入口函数的地址
- arg:给线程入口函数的传参
- 返回值:成功返回0,失败返回错误号errno。以前学过的系统函数都是成功返回0,失败返回-1,而错误号保存在全局变量errno中,而pthread库的函数都是通过返回值返回错误号,虽然每个线程也都有一个errno,但这是为了兼容其它函数接口而提供的,pthread库本身并不使用它,通过返回值返回错误码更加清晰。
pthread_create成功返回后,新创建的线程的id被填写到thread参数所指向的内存单元。我们知道进程id的类型是pid_t,每个进程的id在整个系统中是唯一的,调用getpid(2)可以获得当前进程的id,是一个正整数值。线程id的类型是thread_t,它只在当前进程中保证是唯一的,在不同的系统中thread_t这个类型有不同的实现,它可能是一个整数值,也可能是一个结构体,也可能是一个地址,所以不能简单地当成整数用printf打印,调用pthread_self(3)可以获得当前线程的id。
相关命令
pstack [pid]——查看进程中各个进程的执行调用堆栈
top命令
[1]——可以查看各个cpu的负载
[-H] [-p] [pid]——可以查看各个线程的工作状态
线程终止
1.从入口函数的return返回,该线程就退出掉了。
注意:对主线程来说,从main函数return相当于调用exit,任意线程调用了exit(),或者主线程执行了return 语句(即在main()函数中),都会导致进程中的所有线程立即终止。
2.线程可以调用pthread_exit终止自己。
void pthread_exit(void*retval);
retval:返回信息,可以给也可以不给,是返回给等待线程退出的执行流的。如果不给,则传递NULL,谁调用谁退出。
注意:pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
3.一个线程可以调用pthread_cancel终止同一进程中的另一个线程。
int pthread_cancel (pthread_t thread);
thread:线程标识符
注意:调用该函数的执行流可以取消其他线程,但是需要知道其他线程的线程标识符,也可以执行流自己取消自己,传入自己的线程标识符获取自己的线程标识符:pthread_self()。
线程等待
为什么需要线程等待?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会复用刚才退出线程的地址空间。
注意:线程在创建出来的时候,属性当中默认是joinable属性(意味着线程在退出的时候需要其他执行流来回收线程的资源)
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
调用该函数的线程将挂起等待,直到id为thread的线程终止。
- thread要等待的线程的标识符;
- thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
如果thread线程通过return返回,value_ptr所指向的单元里存放的是thread线程入口函数的返回值。
如果thread线程被别的线程调用pthread_cancel终止掉,value_ptr所指向的单元里存放的是常数PTHREAD_CANCELED
内核当中是#define PTHREAD_CANCELED ((void *) -1)
如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
如果对thread线程的终止状态不感兴趣,可以传NULL给value_ptr参数。
注意:调用该函数的执行流在等待线程退出的时候,该执行流是阻塞在pthread_join函数当中的。
线程分离
默认情况下,线程是可连接的(joinable),也就是说,当线程退出时,其他线程可以通过调用pthread_join()获取其返回状态。有时,程序员并不关心线程的返回状态,只是希望系统在线程终止时能够自动清理并移除之。在这种情况下,可以调用pthread_detach()并向thread 参数传入指定线程的标识符,将该改变线程的属性,将joinable属性改变成为detach属性,当线程退出的时候,不需要其他线程在来回收退出线程的资源,操作系统会默认回收掉,而不保留终止状态。
不能对一个已经处于detach状态的线程调用pthread_join,这样的调用将返回EINVAL。
对一个尚未detach的线程调用pthread_join或pthread_detach都可以把该线程置为detach状态,也就是说,不能对同一线程调用两次pthread_join,或者如果已经对一个线程调用了pthread_detach就不能再调用pthread_join了。
#include <pthread.h>
int pthread_detach(pthread_t thread);
返回值:成功返回0,失败返回错误号。
//使用 pthread_detach(),线程可以自行分离:
pthread_detach(pthread_self());
其他线程调用了exit(),或是主线程执行return 语句时,即便遭到分离的线程也还是会受到影响。此时,不管线程处于可连接状态还是已分离状态,进程的所有线程会立即终止。
换言之,pthread_detach()只是控制线程终止之后所发生的事情,而非何时或如何终止线程。
线程安全——或者说怎么算是线程不安全了
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
1.线程不安全的情况
抢票程序——最后一张票被两个黄牛都抢到了。
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
2.线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
3.线程不安全的原理
3.1 结论
线程不安全会导致程序结果出现二义性
3.2 举例
- 假设现在在(单处理器平台下)同一个程序当中有两个线程,线程A和线程B,并且有一个int类型的全局变量,值为10;线程A和线程B在各自的入口函数当中都对这样的一个全局变量进行++操作;
- 线程A拥有CPU之后,对全局变量进行++操作,但是因为++操作并非是原子操作,也就是意味着线程A,在执行++的过程当中有可能会被打断。假设,线程A刚刚才将全局变量的数值10读到CPU的寄存器当中来,就被切换出去了;但是线程A中的程序计数器当中保存下一条执行的指令,上下文信息当中保存寄存器的值,这两个东西是用来在线程A再次拥有CPU的时候,用来恢复恢复现场的;
- 在A被切换出去的时候,线程B拥有了CPU资源,对全局变量进行了++,并且成功将10++成了11,回写到内存当中了;
- 当线程A再次拥有CPU资源之后,恢复现场,继续往下执行,从寄存器当中读到的值仍然是10,加完之后为11,回写到内存当中也是11;
- 总结:理论上,线程A和线程B各自对全局变量进行了加1操作,理论上全局变量的值应该变成12,但是,现在程序计算的结果是11,所以这就是线程不安全。
4.解决方法——互斥与同步
临界资源是指每次仅允许一个进程访问的资源。
属于临界资源的硬件有打印机、磁带机等;
软件有消息缓冲队列、变量、数组、缓冲区等。
诸进程间应采取互斥方式,实现对这种资源的共享。
每个进程中访问临界资源的那段代码称为临界区。
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
线程同步
对于多线程的程序,访问冲突的问题是很普遍的,解决的办法是引入互斥锁(Mutex,Mutual Exclusive Lock),获得锁的线程可以完成“读-修改-写”的操作,然后释放锁给其它线程,没有获得锁的线程只能等待而不能访问共享数据,这样“读-修改-写”三步操作组成一个原子操作,要么都执行,要么都不执行,不会执行到中间被打,也不会在其它处理器上并行做这个操作。
互斥
1.想要保证互斥,我们需要用到互斥锁。
2.互斥锁本身也是一个资源,或者说我们也需要在代码当中来获取互斥锁;一定要只要多个线程想要保证互斥,需要都去获取互斥锁,否则就无法保证互斥。
互斥锁
1.本质:在互斥锁内部当中有一个计数器,其实就是互斥量计数器的取值只能为0或者1。当线程获取互斥锁的时候,如果计数器当中的值为0,表示当前线程获取不到互斥锁,也就是不能获取互斥锁,就无法去获取临界资源了。当线程获取互斥锁的时候,如果计数器当中的值为1,表示当前线程能获取到互斥锁,也就是意味着可以访问临界资源,代码可以执行临界区当中的代码。
2.计数器当中的值如何保证原子性?
为什么计数器当中的值从0变成1,或者从1变成0,是原子操作?
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪代码改一下(以x86的xchg指令为例):
获取锁资源的时候(也就是在加锁的时候):
寄存器当中的值直接赋值成为0
将寄存器当中的值和计数器当中的值进行交换
判断寄存器当中的值,
当寄存器当中的值为1时,则表示可以加锁;
当寄存器当中的值为0是,则表示不能加锁。
锁的初始化
静态初始化
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t的类型是一个结构体,PTHREAD_MUTEX_INITIALIZER 宏定义了一个结构体的值。
#define PTHREAD_MUTEX_INITIALIZER \
{{0,0,0,0,0,_PTHREAD_SPINS,{0,0}}}
动态初始化
#include <pthread.h>
int pthread_mutex_init
(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
-
pthread_mutex_init会初始化互斥量
-
mutex:传入互斥锁变量的地址
-
attr:互斥量属性,一般传递NULL,采用默认属性
-
返回值:成功返回0,失败返回错误号
pthread_mutex_t lock_;
pthread_mutex_init(&lock_,NULL);
在如下情况下,必须使用函数 pthread_mutex_init(),而非静态初始化互斥量:
动态分配于堆中的互斥量。例如,动态创建针对某一结构的链表,表中每个结构都包含一个pthread_mutex_t 类型的字段来存放互斥量,借以保护对该结构的访问。
互斥量是在栈中分配的自动变量。
初始化经由静态分配,且不使用默认属性的互斥量。
当不再需要经由自动或动态分配的互斥量时,应使用 pthread_mutex_destroy()将其销毁。
(对于使用PTHREAD_MUTEX_INITIALIZER 静态初始化的互斥量,无需调pthread_mutex_destroy()。)
加锁
阻塞加锁接口
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 返回值:成功返回0,失败返回错误号。
- 如果mutex当中的计数器的值为1,则pthread_mutex_lock接口就返回了,表示说加锁成功,同时计数器当中值会被更改为0。
- 如果mutex当中的计数器的值为0,则pthread_mutex_lock接口就阻塞挂起等待了,pthread_mutex_lock接口没有返回,阻塞在该函数的内部,直到另一个线程调用pthread_mutex_unlock释放Mutex,当前线程被唤醒,才能获得该Mutex并继续执行。
- 如果一个线程既想获得锁,又不想挂起等待,可以调用pthread_mutex_trylock,如果Mutex已经被另一个线程获得,这个函数会失败返回EBUSY,而不会使线程挂起等待。
阻塞加锁接口
#include <pthread.h>
int pthread_mutex_trylock(pthread_mutex_t *mutex);
- 当互斥锁变量当中的计数器置位1,则加锁成功返回
- 当互斥锁变量当中的计数器置为0,也会返回,但是加锁并没有成功,也就是它还不能去访问临界资源;
- 一般非阻塞接口都需要搭配循环来使用
带有超时时间的加锁接口
#include <pthread.h>
int pthread_mutex_timedlock
(pthread_mutex_t *mutex,const struct timespec * abs_timeout);
- 带有超时时间的接口,也就是意味着当不能直接获取互斥锁的时候,会等待abs_timeout时间;
- 如果在这个时间内加锁成功了,直接返回,不需要在继续等待剩余的时间,并且表示加锁成功;
- 如果超过该事件,也返回掉了,但是表示加锁失败了,需要循环加锁;
解锁
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 不管是哪一个加锁接口加锁成功的,都可以使用该接口进行解锁。
- 解锁的时候,会将互斥锁变量当中的计数器的值,从0变成1,表示其他线程可以获取互斥锁。
毁锁
针对的是动态初始化的互斥锁
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
总结
更改黄牛抢票程序:
1.在哪里初始化互斥锁
2.在哪里进行加锁
开始访问临界资源的时候就需要加锁;
3.在哪里进行解锁
在所有有可能导致线程退出的地方进行解锁,否则执行流有可能带着锁就退出了,其他执行流就拿不到锁了
4.释放互斥锁
同步
线程A需要等某个条件成立才能继续往下执行,现在这个条件不成立,线程A就阻塞等待,而线程B在执行过程中使这个条件成立了,就唤醒线程A继续执行。在pthread库中通过条件变量(Condition Variable)来阻塞等待一个条件,或者唤醒等待这个条件的线程。
1.同步是为了保证各个线程对临界资源访问的合理性
2.条件变量
本质:PCB等待队列+一堆接口(等待接口+唤醒接口)
条件变量初始化
#include <pthread.h>
//动态初始化
int pthread_cond_init(pthread_cond_t *cond,pthread_condattr_t *attr);
//静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_cond_init函数初始化一个Condition Variable,attr参数为NULL则表示缺省属性。如果条件变量是静态分配的,也可以用宏定义PTHEAD_COND_INITIALIZER初始化,相当于用pthread_cond_init函数初始化并且置attr参数为NULL。
当不再需要一个经由自动或动态分配的条件变量时,应调用pthread_cond_destroy()函数予以销毁。对于使用PTHREAD_COND_INITIALIZER 进行静态初始化的条件变量,无需调用pthread_cond_destroy()。
等待——将调用该接口的线程放到PCB等待队列当中
条件变量的主要操作是发送信号(signal)和等待(wait)。发送信号操作即通知一个或多个处于等待状态的线程,某个共享变量的状态已经改变。等待操作是指在收到一个通知前一直处于阻塞状态。
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
//cond为条件变量
//mutex为互斥锁
pthread_cond_wait()函数将阻塞一线程,直至收到条件变量 cond 的通知。
关于pthread_cond_wait接口:
1.为什么需要互斥量
1.1同步并没有保证互斥,而保证互斥需要使用到互斥锁(条件变量总是要与一个互斥量相关)
1.2pthread_cond_wait内部会进行解除互斥锁
问题:到底是先放到PCB等待队列,还是先解锁?
答:一定先放到PCB等待队列当中,在进行解锁。
2.该接口的内部实现逻辑
- 将调用pthread_cond_wait函数的执行流放到PCB等待队列当中
- 解互斥锁
- 等待被唤醒
问题:假设被唤醒之后,应该如何做?
①从PCB等待队列当中移除出来
②抢占互斥锁
情况①:拿到互斥锁,pthread_cond_wait函数就返回了
情况②:没有抢到互斥锁,阻塞在pthread_cond_wait函数内部抢锁的逻辑当中。一定要知道,当卡在pthread_cond_wait内部抢锁逻辑的执行流时,一旦时间片耗尽,意味着当前线程被切换出来,程序计数器当中保存的就是抢锁的指令,上下文信息当中保存的就是寄存器当中的值。当再次拥有CPU时间片之后,从程序计数器和上下文信息当中恢复抢锁的逻辑。直到抢锁成功,pthread_cond_wait函数才会返回。
唤醒
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_signal()作用——通知PCB等待队列当中的线程,将其从队列当中移出来,唤醒该线程——唤醒至少一个PCB等待队列当中的线程。
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);
pthread_cond_broadcast()作用——唤醒在PCB等待队列当中的所有线程。
销毁(释放)
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
对某个条件变量而言,仅当没有任何线程在等待它时,将其销毁才是安全的。如果条件变量驻留于某片动态创建的内存区域,那么应在释放该内存区域前就将其销毁。经由自动分配的条件变量应在宿主函数返回前予以销毁。
经pthread_cond_destroy()销毁的条件变量,之后可以调用pthread_cond_init()对其进行重新初始化。
线程提供的强大共享是有代价的。多线程应用程序必须使用互斥量和条件变量等同步原语来协调对共享变量的访问。互斥量提供了对共享变量的独占式访问。条件变量允许一个或多个线程等候通知:其他线程改变了共享变量的状态。
死锁
当多个执行流使用同一个互斥锁的时候,有一个执行流获取到了互斥锁之后,但是没有释放互斥锁,导致其他执行流都卡死在加锁的接口当中,我们称之为这种现象是死锁。
多个执行流,多个互斥锁的情况下,每一个执行流都占有一把互斥锁,但是还有申请对方的互斥锁,这种情况下,就会导致各个指令流都阻塞掉,这种现象称之为死锁。
gdb调试技巧:
查看多线程的调用堆栈,可以使用
thread apply all bt
跳转到具体线程的堆栈当中
t[线程编号]
线程编号就是gdb的调试的时候,看到的Thread[num]
跳转到具体的某一个堆栈当中去
f[堆栈编号]
如果说程序死锁之后,如何直接调试每一个线程
gdb attach[pid]:将进程附加上gdb
死锁的四个必要条件
1.互斥条件
2.请求与保持条件(吃着碗里的看着锅里的)
3.不可剥夺条件
4.循环等待
预防死锁
1.破坏必要条件
2.加锁顺序一致
3.不要忘记解锁,在所有的可能导致执行流退出的地方都需要进行解锁
避免死锁算法
1.死锁检测算法
2.银行家算法
生产者消费者模型——一个队列两种角色三种关系
(针对典型应用场景设计出来的解决方案---在任务流程中既要产生数据,又要处理数据的一种场景)
1个线程安全的队列
-
队列的特性——先进先出,所有满足先进先出特性的结构体我们都可以称之为队列
-
线程安全——需要保证在同一时刻,队列当中的元素只有一个执行流去访问(互斥锁+条件变量)
2种角色的线程
-
生产者和消费者
3种关系
-
生产者与生产者互斥
-
消费者与消费者互斥
-
生产者与消费者同步+互斥
优点
- 支持忙闲不均
- 生产者与消费者解耦开来
- 支持高并发
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <iostream>
#include <queue>
#define THREADCOUNT 2
//生产者与消费者模型
// 1.线程安全的队列
// std::queue
// 互斥: pthread_mutex_t
// 同步:pthread_cond_t
// 2.两种的角色的线程
// 生产者线程 --》生产者线程的入口函数
// 消费线程 --》消费线程的入口函数
class BlockQueue
{
public:
BlockQueue()
{
capacity_ = 10;
pthread_mutex_init(&lock_, NULL);
pthread_cond_init(&prod_cond_, NULL);
pthread_cond_init(&cons_cond_, NULL);
}
~BlockQueue()
{
pthread_mutex_destroy(&lock_);
pthread_cond_destroy(&cons_cond_);
pthread_cond_destroy(&prod_cond_);
}
//写多线程代码的时候
// 1.考虑业务核心逻辑
// 2.考虑核心逻辑当中是否访问临界资源或者说执行临界区代码,
// 如果有需要保证互斥
// 3.需不需要保证各个线程之间同步
//生产者线程调用
void Push(int data)
{
pthread_mutex_lock(&lock_);
while(que_.size() >= capacity_)
{
pthread_cond_wait(&prod_cond_, &lock_);
}
que_.push(data);
pthread_mutex_unlock(&lock_);
pthread_cond_signal(&cons_cond_);
}
//消费者线程调用的
void Pop(int* data)
{
pthread_mutex_lock(&lock_);
while(que_.empty())
{
pthread_cond_wait(&cons_cond_, &lock_);
}
*data = que_.front();//拿队列首部元素的值
que_.pop();//出队操作
pthread_mutex_unlock(&lock_);
pthread_cond_signal(&prod_cond_);
}
private:
//STL当中的queue是不安全的
std::queue<int> que_;
//队列设置一个容量, 不能无节制的让队列一直进行扩容,
//有可能会导致我们申请内存失败或者当前程序被操作系统干掉
size_t capacity_;
//保证STL当中的queue同步和互斥
pthread_mutex_t lock_;
pthread_cond_t prod_cond_;
pthread_cond_t cons_cond_;
};
void* ConsumeStart(void* arg)
{
BlockQueue* bq = (BlockQueue*)arg;
while(1)
{
//从线程安全队列当中获取数据进行消费
int data;
bq->Pop(&data);
printf("i am %p, i consume %d\n", pthread_self(), data);
}
return NULL;
}
void* ProductStart(void* arg)
{
BlockQueue* bq = (BlockQueue*)arg;
//如果是多个线程, 每个线程都会在自己独有的栈当中压栈该入口函数,
//data为临时变量, 每一个线程都是拥有一个data这样的临时
int data = 0;
while(1)
{
//往线程安全队列当中插入数据
bq->Push(data);
//调用完毕Push之后, 互斥锁就释放了
//这会有可能该执行流时间片到了, 线程切换出去了
//printf并不是原子性的
printf("i am %p, i product %d\n", pthread_self(), data);
data++;
}
return NULL;
}
int main()
{
pthread_t cons[THREADCOUNT], prod[THREADCOUNT];
BlockQueue* bq = new BlockQueue();
for(int i = 0; i < THREADCOUNT; i++)
{
int ret = pthread_create(&cons[i], NULL, ConsumeStart, (void*)bq);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
ret = pthread_create(&prod[i], NULL, ProductStart, (void*)bq);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
}
for(int i = 0; i < THREADCOUNT; i++)
{
pthread_join(cons[i], NULL);
pthread_join(prod[i], NULL);
}
return 0;
}
posix版本的信号量
本质:计数器+PCB等待队列+一堆的接口(等待接口+唤醒接口)
计数器:本质是对资源的计数
- 当执行流获取信号量成功之后,信号量当中的计数器会进行减1操作当获取失败之后,该执行流就会被放到PCB等待队列当中
- 当执行流释放信号量成功之后,信号量当中的计数器会进行加1
使用流程
1.定义信号量sem_t
2.初始化信号量初值 sem_init(sem_t*,int pshared,int val);
3.在访问资源之前先访问信号量判断是否符合访问条件,若不满足则阻塞:sem_wait(semt*)/sem_trywait(sem_t*)/sem_timedwait()
4.在访问资源条件满足之后,计数+1,唤醒阻塞的线程:sem_post(semt*);
5.销毁信号量sem_destroy(semt*);
操作接口
初始化信号量
#include <semaphore.h>
int sem_init(sem_t*sem,int pshared,unsigned int value);
sem:传入信号量的地址,semt信号量的类型
pshared:该信号量是用于线程间还是用于进程间
0:用于线程间,全局变量
非0:用于进程间将信号量的所用到资源在共享内存当中进行开辟
value:资源的个数,本质上是初始化话信号量计数器的
注意:信号量可以完成线程与线程之间的同步与互斥,也可以完成进程与进程之间的同步与互斥。
互斥
1.初始化信号量当中的计数器为1,表示说只有一个资源可以被使用;
2.当执行流A想要访问临界资源的时候,首先会去获取信号量,由于计数器当中的值为1,表示可以访问,计数器的值从1变成0。从而执行流A去访问临界资源。
3.此时当执行流B想要访问临界资源的时候,获取信号量,但是若此时计数器当中的值为0,表示不能够访问临界资源,执行流B的PCB就被放到了PCB等待队列当中,同时信号量当中的计数器的值减1(也就是从0减成-1),-1表示当前还有1个执行流在等待访问临界资源。
同步
1.不要求信号量当中的计数器一定为1,也可以为其他正整数。
2.当执行流想要访问临界资源的时候,首先获取信号量
2.1如果信号量当中的计数器大于0,则表示能够访问临界资源,则该执行流不会阻塞,顺序执行临界区代码。
2.2如果信号量当中的计数器值小于等于0,则表示不能访问临界资源,则该执行流会被放到PCB等待队列当中,同时计数器也会进行减1操作;
注意:如果计数器的值为负数,表示当前还有计数器的绝对值个执行流在等待。
3.当释放信号量的时候,会对信号量当中的计数器进行加1操作,是否唤醒PCB等待队列当中的执行流呢?
3.1计数器加1操作之后还为负数或者为0,则需要通知PCB等待队列当中的执行流
3.2计数器加1操作之后为正数,则不需要通知PCB等待队列。
等待接口
#include <semaphore.h>
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
注意:调用该接口的执行流会对计数器进行减1操作
1.如果减1操作完毕之后计数器的值是大于等于0的,表示可以访问临界资源,意味着sem_wait函数会返回;
2.如果减1操作完毕之后计数器的值是小于0的,调用该接口的执行流被阻塞,该执行流被放到PCB等待队列当中。
释放信号量
#include <semaphore.h>
int sem_post(sem_t *sem);
调用该接口的执行流会对计数器进行加1操作。
销毁信号量
#include <semaphore.h>
int sem_destroy(sem_t *sem);
信号量版本的生产者与消费者模型
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>
#include <vector>
#include <iostream>
//线程安全的队列
// 只要满足先进先出特性的数据结构都可以称之为队列
// 数组是否可以实现一个队列???
// 1.读写下标的计算: pos = (pos+1) % 数组大小
// 2.对读写数组实现线程安全的时候
// 互斥:sem_t lock; sem_init(&lock, 0, 1);
// 同步:
// 生产者信号量:sem_t prod; sem_init(&prod, 0, 数组大小)
// 由于一开始数组当中没有空间可以读, 则计数器的初始值为0
// 消费者信号量:sem_t cons; sem_init(&cons, 0, 0);
#define CAPACITY 4
class RingQueue
{
public:
RingQueue()
:vec_(CAPACITY)
{
capacity_ = CAPACITY;
sem_init(&lock_, 0, 1);
sem_init(&prod_, 0, capacity_);
sem_init(&cons_, 0, 0);
pos_write_ = 0;
pos_read_ = 0;
}
~RingQueue()
{
sem_destroy(&lock_);
sem_destroy(&prod_);
sem_destroy(&cons_);
}
void Push(int data)
{
sem_wait(&prod_); //1
sem_wait(&lock_); //2
vec_[pos_write_] = data;
pos_write_ = (pos_write_ + 1) % capacity_;
sem_post(&lock_);
sem_post(&cons_);
}
void Pop(int* data)
{
sem_wait(&cons_); //1
sem_wait(&lock_); //2
*data = vec_[pos_read_];
pos_read_ = (pos_read_ + 1) % capacity_;
sem_post(&lock_);
sem_post(&prod_);
}
private:
std::vector<int> vec_;
int capacity_;
sem_t lock_;
sem_t prod_;
sem_t cons_;
//读写位置
int pos_write_;
int pos_read_;
};
int main()
{
return 0;
}
线程池
线程池:线程的池子,有很多线程,但是数量不会超过池子的限制。--需要用到多执行流进行任务处理的时候,就从池子取出。
应用场景:有大量的数据处理请求,需要多执行流并发/并行处理。
若是一个数据请求的到来伴随一个线程的创建去处理,则会产生一些风险以及一些不必要的消耗:
1.线程若不限制数量的创建,在峰值压力下,线程创建过多,资源耗尽,由程序崩溃的风险
2.处理一个任务的时间:创建线程时间t1+任务处理时间t2+线程销毁时间t3=T,若t2/T比例占比不够高,则表示大量的资源用于线程的创建与销毁成本上,因此线程池使用已经创建好的线程进行循环任务处理,就避免了大量线程的频繁创建与销毁的时间成本。
自主编写一个线程池:大量线程(每个线程中都是进行循环的任务处理)+任务缓冲队列
线程的入口函数,都是在创建线程的时候,就固定传入的,导致线程池中的线程进行任务处理的方式过于单一。
因为线程的入口函数都是一样的,处理流程也就都是一样的,只能处理单一方式的请求。
---灵活性太差
若任务队列中的任务,不仅仅是单纯的数据,而是包含任务处理方法在内的数据,这时候,线程池中的线程只需要使用传入的方法,处理传入的数据即可,不需要关心是什么数据,如何处理。
---提高线程池的灵活性。
3.使用
3.1线程池提供了一个push接口,用来支持请求入队操作
3.2线程池当中的线程,从队列当中获取数据,进行处理
4.线程池当中的线程都是等价的,逻辑上可以认为是消费线程。所有的线程都是调用同样的一个入口函数。
5.线程安全队列当中的元素 = 待处理的数据 + 处理数据的函数
如何让线程池当中的线程优雅的退出?
背景:担心线程直接退出,而导致线程池当中线程安全队列里面还有数据没有处理。
线程池当中线程可能存在的几种情况:
1.加互斥锁--》加锁--》判断队列是否为空--》IsExit--》pthread_exit
2.调用pthread_cond_wait当中|阻塞在pthread_cond_wait接口当中
3.在队列当中获取数据获取成功--》处理数据-》while--》加锁--》判断队列是否为空-》lsExit--》pthread_exit
4.正在处理队列里面的数据》处理数据---》while--》加锁--》判断队列是否为空--》IsExit--》pthread_exit结论:只有当线程判断了队列当中没有数据的情况下才可以退出
捎带去处理标志位
false->true接口,将标志位false-->true2.唤醒PCB等待队列当中的线程:
#include <stdio.h> #include <unistd.h> #include <pthread.h> #include <queue> typedef void (*Handler)(int); class QueueData { public: QueueData(int data, Handler Handler) { data_ = data; handler_ = Handler; } ~QueueData() { } #if 0 Handler GetHanler() { return handler_; } int GetData() { return data_; } Handler Get(int* data) { } #endif void Run() { handler_(data_); } private: int data_; Handler handler_; }; class ThreadPool { public: ThreadPool(int capacity, int thread_count) { capacity_ = capacity; thread_count_ = thread_count; pthread_mutex_init(&lock_, NULL); pthread_cond_init(&cons_cond_, NULL); flag = 0; } ~ThreadPool() { pthread_mutex_destroy(&lock_); pthread_cond_destroy(&cons_cond_); } int Oninit() { pthread_t tid; //创建线程 for(int i = 0; i < thread_count_; i++) { int ret = pthread_create(&tid, NULL, PoolStart, (void*)this); if(ret < 0) { perror("pthread_create"); return -1; } } return 0; } int Push(QueueData* qd) { pthread_mutex_lock(&lock_); if(flag) { pthread_mutex_unlock(&lock_); return -1; } que_.push(qd); pthread_mutex_unlock(&lock_); pthread_cond_signal(&cons_cond_); return 0; } void ThreadExit() { pthread_mutex_lock(&lock_); flag = 1; pthread_mutex_unlock(&lock_); pthread_cond_broadcast(&cons_cond_); } private: void Pop(QueueData** qd) { *qd = que_.front(); que_.pop(); } static void* PoolStart(void* arg) { pthread_detach(pthread_self()); ThreadPool* tp = (ThreadPool*)arg; while(1) { //pos 1 ==> no pthread_mutex_lock(&tp->lock_); //pos 2 ==> no while(tp->que_.empty()) { //才有可能退出线程 if(tp->flag) { tp->thread_count_--; pthread_mutex_unlock(&tp->lock_); pthread_exit(NULL); } //pos 3 PCB等待队列 ==> no pthread_cond_wait(&tp->cons_cond_, &tp->lock_); } //pos 4 ==> no QueueData* qd; tp->Pop(&qd); pthread_mutex_unlock(&tp->lock_); //pos 5 ==> no qd->Run(); delete qd; } } private: std::queue<QueueData*> que_; size_t capacity_; pthread_mutex_t lock_; //pthread_cond_t prod_cond_; pthread_cond_t cons_cond_; //线程的数量个数 int thread_count_; int flag; }; void DealData(int data) { printf("data:%d\n", data); } int main() { ThreadPool* tp = new ThreadPool(4, 2); if(!tp) { printf("create ThreadPool failed\n"); return -1; } int ret = tp->Oninit(); if(ret < 0) { printf("create ThreadPool failed\n"); return -1; } for(int i = 0; i < 100; i++) { QueueData* qd = new QueueData(i, DealData); if(!qd) { continue; } tp->Push(qd); } sleep(10); tp->ThreadExit(); delete tp; return 0; }
设计模式
解释:高级程序员将自己的编程经验剥离出来,针对一些常见的问题或者常见的场景,给出一种解决方案,设计成为一种套路。
设计模式的优点:
- 代码复用程度高
- 程序比较可靠,并且容易理解
- 代码框架比较稳定
设计模式的分类:
- 创建型模式——单例模式
- 结构型模式——适配器模式
- 行为型模式——观察者模式
单例模式:
特点:全局提供唯一一个类的实例,具有全局变量的特点。
使用场景:内存池,数据池。
基础的要点:全局只有一个实例——static+禁止构造+禁止拷贝构造+禁止赋值拷贝线程安全,调用者通过类的函数来获取实例。
具体实现:
a.饿汉模式
相当于每次吃完饭,就洗掉碗,后面在吃饭的时候就直接拿来用了程序启动的时候进行初始化,资源在程序初始化的时候就全部加载完毕了。
优点:程序运行速度很快,流畅,实现简洁。
缺点:在main函数之前初始化实例,如果程序中单例较多,程序初始化的时候就耗时比较长,启动慢。除此之外,若有两个实例有依赖关系,它两个初始化的先后顺序是没办法控制的。
template <typename T> class Singleton { static T data;//类内声明,类外定义。 public: static T* GetInstance() { return &data; } }; T Singleton::data = 99;
b.懒汉模式
每次吃饭完都不洗碗,第二次吃饭的时候,才进行洗碗,资源在使用的时候才进行实例化,单例类的对象在使用的时候才进行实例化。
优点:程序初始化的时候比较快,不影响程序的启动。
缺点:运行的时候没有饿汉模式流畅,线程安全,实现相对复杂。
// 懒汉模式, 线程安全 template <typename T> class Singleton { volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化. static std::mutex lock; public: static T* GetInstance() { if (inst == NULL) { // 双重判定空指针, 降低锁冲突的概率, 提高性能. lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new. if (inst == NULL) { inst = new T(); } lock.unlock(); } return inst; } };
注意事项:
1. 加锁解锁的位置
2. 双重 if 判定, 避免不必要的锁竞争
3. volatile关键字防止过度优化
static T* GetInstance() {
if (inst == NULL) {
inst = new TO);
}
return inst;
}
如果没有双重判定空指针的话,如上图所示,存在一个严重的问题, 线程不安全,第一次调用 GetInstance 的时候,如果两个线程同时调用,可能会创建出两份T对象的实例,但是后续再次调用, 就没有问题了。
读写锁——是一种读共享,写独占的锁
场景:少量读+大量写。(多读少写)
三种状态:读模式下的加锁状态,写模式下的加锁状态,不加锁的状态。
读写锁的特性:
当读写锁被加了写锁时,其他线程对该锁加读锁或者写锁都会阻塞(不是失败)。
当读写锁被加了读锁时,其他线程对该锁加写锁会阻塞,加读锁会成功。
加锁规则:
同一时刻,只能有一个线程以写模式占有读写锁;和互斥锁非常的类似同一时刻,允许有多个线程以读模式占有读写锁,读写的内部有一个引用计数,当有线程以读模式占有读写锁的时候引用计数就会++。
解锁规则:
都可以使用解锁接口来对不同的加锁模式进行解锁。
接口:
- 定义读写锁
pthread_rwlock_t rwlock_;
- 初始化读写锁
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t*,pthread_rwlockattr_t*);
pthread_rwlock_t:读写锁的类型,一般情况下在使用的时候,都是传入变量的地址
pthread_rwlockattr_t:读写锁的属性,一般传递NULL,采用默认属性
thread_rwlock_t rw_lock;
pthread_rwlock_init(&rw_lock,NULL);
- 以读模式加锁
最大的用处:允许多个线程以读加锁的方式获取到读写锁本质上,读写锁当中维护了一个引用计数,每当线程以读方式获取了读写锁,该引用计数进行++。
当释放以读模式方式加读写锁的时候,会先对引用计数进行--,直到引用计数的值为0的时候,才真正释放了这把读写锁。
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t*);
int pthread_rwlock_tryrdlock(pthread_rwlock_t*rwlock);//非阻塞类型的读加锁接口
- 以写模式加锁
#include <pthread.h>
int pthread_rwlock_wrlock(pthread_rwlock_t*);
- 释放锁
#include <pthread.h>
int pthread_rwlock_unlock(pthread_rwlock_t*);
不管是以读方式获取的读写锁,还是以写方式获取的读写锁使用该解锁接口都可以进行解锁。
若以读模式方式加锁,引用计数为0的时候才真正释放了读写锁。
- 销毁锁
#include <pthread.h>
int pthread_rwlock_destroy(pthread_rwlock_t*);
STL中的容器都是线程安全的吗?
不是,原因是STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。因此 STL 默认不是线程安全,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
智能指针是线程安全的吗?
因为局部操作/原子操作,不涉及线程安全的问题。
-
uniqueptr局部对象,不涉及线程安全
-
shared ptr通过对指针引用计数(涉及线程安全的问题),然而这个问题,智能指针在内部通过一个CAS锁实现了原子操作,确定什么时候释放指针所指向的空间,所以是线程安全的
智能指针是实例化的一个数据对象,在资源释放的时候会顺带释放指针的所指向的空间。
malloc:在堆上申请内存,这种内存需要用户自己手动进行free释放,如果没有free就会造成资源泄漏、通过对一个指针实例化一个只能指针对象,好处是智能指针对象释放的时候,也会顺带释放指针所指向的空间,避免的遗忘free/delete的情况。
乐观锁
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。乐观锁适用于读操作多的场景,这样可以提高程序的吞吐量。
乐观锁机制采取了更加宽松的加锁机制。乐观锁是相对悲观锁而言,也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:
- CAS实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种CAS实现方式。
- 版本号控制:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数。当数据被修改时,version值会+1。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值与当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
说明:乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。
悲观锁
当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制 。【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】
悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制。(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)
之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起,悲观锁的实现:
-
传统的关系型数据库使用这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁
-
Java 里面的同步 synchronized 关键字的实现
悲观锁主要分为共享锁和排他锁:
共享锁【shared locks】:又称为读锁,简称S锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
排他锁【exclusive locks】:又称为写锁,简称X锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据行读取和修改。
说明:悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。