Linux线程
Linix线程基础
1.概念
线程是一个进程内部的控制序列,是cpu调度的基本单位,一个进程内部至少有一个线程。其中单线程是指进程内部只有一个线程,其只能一个接一个地处理任务;多线程是指进程内部有多个线程,其可以用于同时执行不同的任务(这里的同时是指cpu可以将当前线程任务执行一部分,转而去执行另一个线程的任务,在我们看来就好像是所有任务在同时执行),多线程在实际中具有非常重要的意义:
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
- 合理的使用多线程,能提高CPU密集型程序的执行效率
当然了,一个进程内部的线程不是越多越好,因为线程间切换也是需要一定的成本的,建议cpu有多少核就创建多少个线程。
那么OS是如何管理线程的呢?像OS通过PCB来描述并管理进程一样,OS通过线程控制块(TCB)来描述并管理线程,由于TCB和PCB极为相似,Linux设计者为了管理方便,采用PCB来模拟TCB,这样CPU就不用区分进程和线程,直接对其调用运行就可以了,因此线程在Linux中也叫轻量级进程。但Windows没有采用这种方案,而是自己专门设计了TCB。
2.Linux线程 VS
Linux进程
进程是资源分配的基本单位,而线程是cpu调度执行的基本单位,即每个进程拥有自己唯一的进程地址空间和页表,但对于进程内部的线程而言,所有的线程共享该进程地址空间和页表,线程的调度执行就是在进程地址空间内跳转运行:
线程执行的任务通常是一个函数,而函数在编译之后是一段代码块,函数名就是代码块的入口,每一行代码都有对应的虚拟地址,线程只要得到函数名,就可以执行对应的任务了。对于单线程,如果线程出现除零等错误导致线程崩溃,进程也会随之崩溃;对于多线程,如果某个线程出现异常,也会触发信号机制导致进程终止,该进程内的所有线程也会随之退出。
进程VS线程:
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据:
①一组寄存器
②栈
③信号屏蔽字
④线程ID
⑤调度优先级
⑥errno
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
①文件描述符表
②每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
③代码和全局数据
④当前工作目录
⑤用户id和组id
3.函数重入和线程安全
概念:
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果,我们称之为线程安全。如果对全局变量或者静态变量进行访问修改操作,并且没有锁保护的情况下,会出现线程安全问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况:
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况:
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况:
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全区别:
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
线程优点:
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。这是因为cpu中有一个cache元件缓存着上下文数据,进程间切换意味着要将cache情况重新加载需要切换的进程的数据,而线程切换不需要
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程缺点:
- 性能损失。一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低。编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制。进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高。编写与调试一个多线程程序比单线程程序困难得多。
Linux线程控制
由于Linux没有真正的线程,只有轻量级进程,因此Linux本质上没有真正的与线程相关的系统调用,只有进程的系统调用,但为了让用户感觉自己是在使用线程,Linux将一些进程相关的系统调用进行了封装,将其转化成线程相关接口,因此Linux线程也叫用户级线程,这些线程相关接口被封装成一个名为 phread
的线程库(是一个动态库),处于内核和用户之间,其既不属于C,也不属于C++,而是Linux自带的,所有线程的管理是由库维护的而不是OS,而编译器并不认识该库,如果用户想要使用线程相关接口,在编译时需要手动链接该库,即在编译时加上选项:-lphread
用户可以使用命令 ps -aL
查看当前系统所有进程的信息,特别是线程相关信息:
①PID
:主线程的标识符
②LWP
:轻量级进程的标识符(light weight process)
1. 创建线程:
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
该系统调用成功返回0,失败返回错误码,传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误,但pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做),而是将错误代码通过返回值返回,尽管pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码,对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小。参数说明:
thread
:该参数是一个输出型参数,用于接收创建线程的ID,这里的ID与LWP不一样,但他们是一一对应的关系,只不过ID是给用户使用的,LWP是给内核使用的,如果有·需要用户可以使用函数 pthread_self() 函数获取线程的ID:
#include <pthread.h>
pthread_t pthread_self(void);
attr
:用于设置线程属性,一般传 nullptr 即可,表示使用默认属性
start_routine
:线程启动后要执行的函数的地址,该函数返回值为void* ,参数为void* :
void* thread_fun(void* arg);
arg
:线程启动函数的参数,如果该函数没有参数,传 nullptr 即可
这里解释一下pthread_t是什么数据类型:
pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
2.线程终止 :
如果用户需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用
pthread_ exit()
终止自己。 - 一个线程可以调用
pthread_ cancel()
终止同一进程中的另一个线程。
需要注意的是 exit()
函数会直接终止整个进程,而不仅仅只是终止线程。
- ①
pthread_exit()
#include <pthread.h>
void pthread_exit(void *retval);
该函数由需要终止的线程自己调用,没有返回值,这是因为该函数一旦调用总是执行成功,线程在调用该函数后就终止了,无法接收到返回值,参数说明:
retval
:这是一个void*类型的指针,用于指向线程退出时需要返回的数据。这个返回值可以被其他线程通过调用 pthread_join() 函数来获取。如果线程不需要返回任何数据,可以将此参数设置为 nullptr。
- ②
pthread_cancel()
#include <pthread.h>
int pthread_cancel(pthread_t thread);
这个函数用于在一个线程中终止另一个线程,一般是主线程调用来终止子线程,执行成功返回0,失败返回错误码,参数说明:
thread
:需要终止的线程的ID
需要注意,pthread_exit() 或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
3. 线程等待 :
由于主线程创建一个子线程后,主线程和子线程哪个先运行是不确定的,而主线程一旦退出,该进程就会退出,意味着所有线程都要退出,而我们希望主线程最后退出,就需要主线程在创建子线程后进行线程等待,等待子线程执行完任务。同时已经退出的线程,其空间并没有被释放,仍然在进程的地址空间内,创建新的线程不会复用刚才退出线程的地址空间,这时就需要进行线程等待来回收子线程的资源。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
调用该函数的线程将挂起等待,直到ID为thread的线程终止。该函数执行成功返回0,失败返回错误码,参数说明:
thread
:需要等待的线程的ID
retval
:这是一个用户定义的指针,用于存储被等待线程的返回值。如果用户对这个返回值感兴趣,可以通过这个参数获取。如果用户不关心线程的返回值,将此参数设置为 nullptr 即可。
thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的:
- 如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用 pthread_ cancel() 异常终掉,retval所指向的单元里存放的是常数
PTHREAD_ CANCELED - 如果thread线程是自己调用 pthread_exit() 终止的,retval所指向的单元存放的是传给 pthread_exit 的参数。
4.线程分离 :
默认情况下,新创建的线程是joinable的,线程退出后,需要调用pthread_join() 对其进行等待操作,否则无法释放资源,从而造成系统资源泄漏,但如果我们不关心子线程的返回值,进行等待是一种负担,这个时候我们可以将该线程进行分离,当线程退出时,自动释放线程资源,相当于对某个线程进行非阻塞等待。
#include <pthread.h>
int pthread_detach(pthread_t thread);
调用该函数的线程会将ID为thread的线程进行分离,执行成功返回0,失败返回错误码,参数说明:
thread
:需要等待的线程的ID
用户也可以让线程自己分离:
pthread_detach(pthread_self());
需要注意的是,joinable和分离是冲突的,一个线程不能既是joinable又是分离的。而线程分离只是一种工作状态,其资源依旧是共享的,依旧属于同一个进程,如果主线程退出了,分离的线程也会退出,但无论线程是否分离,我们都希望主线程最后退出,因此我们可以将主线程设置为死循环,直到接收到特定信号再退出(事实上大部分软件都是这样做的,用户启动一个软件后不会自动关闭,用户手动关闭就是发送了特定信号)。
Linux线程互斥
如果多个线程并发访问临界资源时,不对临界资源进行保护的话,可能会引发数据不一致的问题,解决方案是对临界区代码进行加锁,保证只有当持有锁的线程完成对临界资源的所有访问操作后,其他线程才有机会访问临界资源,Linux将该锁称为互斥量。
互斥量相关接口如下:
1.初始化互斥量 :
- 方法① :静态分配
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
- 方法② :动态分配
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
该函数用于初始化一个互斥量,执行成功时,函数返回0,失败时,返回非零错误码,并设置errno以指示错误原因,参数说明:
mutex
:这是一个指向pthread_mutex_t类型变量的指针,该变量将被初始化为一个互斥锁。restrict关键字是一个类型限定符,用于告诉编译器,该指针是访问它所指向对象的唯一方式,这有助于编译器优化代码。
restrictattr
:这是一个指向pthread_mutexattr_t类型变量的指针,用于指定互斥锁的属性,pthread_mutexattr_t是一个用于描述互斥锁属性的结构体,一般传 nullptr 即可,表示使用默认的互斥量属性。
2.互斥量加锁 :
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
该函数表示接下来的代码是受保护的,只有成功申请到互斥量mutex的线程才可以执行接下来的代码,否则线程只能在这里阻塞等待,互斥量mutex由系统根据线程竞争锁的能力分配给其中一个线程,这就保证了任何时候只有一个线程访问临界资源。该函数执行成功返回0,失败返回错误码,参数说明:
mutex
:指向一个已经初始化的互斥量
3.互斥量解锁 :
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);
该函数表示接下来的代码不再受到互斥量mutex保护,申请到mutex的线程执行到此处时就要归还互斥量,各个线程可以重新竞争mutex,该函数执行成功返回0,失败返回错误码,参数说明:
mutex
:指向一个已经初始化的互斥量
4.销毁互斥量 :
如果是使用静态分配的方法初始化的互斥量不需要用户销毁,只有使用动态分配的方法初始化的互斥量需要用户销毁:
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
该函数执行成功返回0,失败返回错误码,参数说明:
mutex
:指向一个已经初始化的互斥量
需要注意的不要销毁一个已经加锁的互斥量,同时已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
位于加锁和解锁之间的代码的大小(即临界区代码的大小)我们称之为粒度,加锁的粒度越小越好。
加锁的方式一般有三种:
- ①使用全局锁,所有线程都可以看到
- ②在主线程中定义局部锁,子线程引用该局部锁
- ③将锁封装成类,即RAII技术
使用锁可以达到保护临界区代码的目的,尽管其逻辑没有问题,但并不代表其一定合理,例如可能存在一些线程竞争锁的能力十分强大,从而引发线程饥饿问题,因此我们需要线程同步来解决这个问题。
现在我们从利用伪代码从汇编层面理解一下互斥量的原理:
lock:
mov al,0
xchg al,mutex
if(al > 0)
{
return 0;
}
else
{
//挂起等待
}
unlock:
mov mutex,1
//唤醒线程去竞争锁
return 0;
相当于互斥量就是一个1,这个1被所有线程抢来抢去,但最终只有一个线程能抢到这个1,抢不到的线程就去休眠。
其他常见的锁
我们前面所说的锁是普通锁,除此之外,还有其他的锁:
悲观锁
:每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。乐观锁
:每次取数据时,总是乐观的认为数据不会被其他线程修改,因此不上锁,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是等则失败,失败则重试,一般是一个自旋的过程,即不断重试。自旋锁
: 当线程申请不到该锁时,线程不会被阻塞休眠,而是不断检查是否可以申请到锁,直至申请成功。
如果线程在临界区时间较长,使用普通锁即可,如果线程在临界区时间较短,推荐使用自旋锁。
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
常见的就是线程申请两个锁时产生死锁:
线程A申请锁b失败,带着锁a进入休眠,线程B申请锁a时申请失败,带着锁b进入休眠。这样线程都在等对方持有的锁,从而导致两个线程都无法执行。
一把锁也会产生死锁:如果当前进程已经持有锁a,在锁a没有释放时。该线程又去申请锁a,就会导致当前线程直接带着锁a进入休眠去等待锁a,从而产生死锁,当然了,没有人会写出这种代码,这种情况完全可以人为避免。
产生死锁需要满足4个条件:
- ①互斥条件:线程对资源的访问是原子的
- ②请求与保持:每个线程都坚持持有已经持有的锁,同时又去申请锁
- ③不剥夺条件:一个线程不能强行将对方线程持有的锁强行夺取过来
- ④循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
只要破环以上四个条件中的任意一个,就可以避免死锁问题,因此有四种方法可以避免死锁:
- 对资源不加锁,对于一些允许多线程同时访问的资源就不必要加锁,相当于破环条件①
- 以持有锁的进程再申请另一个锁失败时,释放已持有的锁,可以使用函数 trylock() 先尝试申请锁,尝试申请失败就释放已经持有的锁,相当于破环条件②
- 强行夺取锁,用户可以在汇编层面强行将mutex的值修改为1,相当于破环条件③,但这种做法不推荐
- 所有线程加锁顺序一致,这样就不会产生线程对资源的循环等待,相当于破环条件④,这显然是目前最优的做法。
现在也有一些算法可以检测是否有死锁,不过正确率不能100%保证:
- 死锁检测算法
- 银行家算法
Linux线程同步
当一个线程在等待某个资源且在得到该资源之前其什么也做不了,就可以将这个线程放到某个队列中,等到某个条件满足后,再把该线程唤醒继续执行任务,如果把所有不满足该条件的线程都放到同一个队列中,条件满足时就按先后顺序唤醒队列里的一个进程,这样就既保护了资源,同时也解决了线程饥饿问题。我们把这种在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题的做法叫做同步。条件变量就是一种用于同步的机制,与互斥锁一起使用,用于在特定条件成立时通知一个或多个等待线程。条件变量一般是在加锁和解锁之间使用的。
1.条件变量初始化 :
#include<pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
该函数执行成功返回0,失败返回错误码,参数说明:
cond
:指向要初始化的条件变量的指针,这个指针必须指向一个有效的 pthread_cond_t 类型的变量。
attr
:指向条件变量属性的指针,这些属性用于定制条件变量的行为。如果传递 NULL ,则使用默认属性,大多数应用程序不需要设置这些属性,因此可以简单地传递 NULL 。
如果是全局的条件变量,也可以采用以下方式进行初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
2.线程等待直至条件满足:
#include<pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
将当前线程放入条件变量cond的等待队列中进行休眠等待,该函数如果成功执行则返回0,如果发生错误则返回错误码,参数说明:
cond
:指向要等待的条件变量的指针,这个条件变量用于线程间的同步。
mutex
:指向与条件变量相关联的互斥锁的指针,因为如果该函数调用成功,需要把当前线程持有的锁进行释放,其他线程才可以访问临界区修改数据,从而使条件满足可以唤醒当前线程。需要注意尽管该锁被提前释放了,但所有线程中依旧是只有竞争到锁的线程才可以访问临界区,因此该函数调用前后临界区依旧是安全的。
3.唤醒等待线程:
- 唤醒该条件变量下的一个线程
#include<pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
该函数如果成功执行则返回0,失败则返回错误码,参数说明:
cond
:指向要发送信号的条件变量的指针
- 唤醒该条件变量下的所有线程
#include<pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);
该函数如果成功执行则返回0,失败则返回错误码,参数说明:
cond
:指向要广播信号的条件变量的指针
需要注意的是唤醒线程不等同于线程可以直接运行,线程被唤醒后需要重新竞争到锁才可以继续执行代码。
其次,如果线程被唤醒后没有立即竞争到锁,其他竞争到锁的线程修改了临界区数据,导致在该条件变量下等待的线程又不满足该条件,但该线程现在却已经被唤醒了,我们称之为虚假唤醒,为了将虚假唤醒的线程再次进入该条件变量下进行等待,我们需要规范化等待条件的代码:
pthread_mutex_lock(&mutex);
//采用循环解决虚假唤醒问题
while (条件为假)
{
pthread_cond_wait(cond, mutex);
}
//修改条件
pthread_mutex_unlock(&mutex);
4.销毁条件变量:
#include<pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
该函数如果成功执行则返回0,失败则返回错误码,参数说明:
cond
:指向要销毁的条件变量的指针
如果是全局的条件变量,不需要用户去手动销毁。
生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过“超市”来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给“超市”就行了,消费者也不找生产者要数据,而是直接从“超市”里取,“超市”就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个“超市”就是用来给生产者和消费者解耦的。
生产者消费者模型优点:
- 解耦
- 支持并发
- 支持忙闲不均
总之生产消费模型可以简记为“321”原则:
3
:即3种关系:
- 生产者 vs 生产者:互斥
- 消费者 vs 消费者:互斥
- 生产者 vs 消费者:互斥且同步
2
:即2种角色:
- 生产者
- 消费者
1
:即一个场所:
- 超市(临界资源)
1.基于BlockingQueue的生产者消费者模型
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞),在这里,阻塞队列就是“超市”这个场所。
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<queue>
#include<vector>
#include<functional>
using namespace std;
//阻塞队列
//T是阻塞队列的数据类型
template<class T>
class BlockQueue
{
public:
BlockQueue(int capacity)
:_capacity(capacity)
{
pthread_mutex_init(&_mutex,NULL);
pthread_cond_init(&_consumer,NULL);
pthread_cond_init(&_producter,NULL);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_consumer);
pthread_cond_destroy(&_producter);
}
void Push(T& data)
{
pthread_mutex_lock(&_mutex);
while(_bq.size()==_capacity)
{
pthread_cond_wait(&_producter,&_mutex);
}
_bq.push(data);
pthread_cond_signal(&_consumer);
pthread_mutex_unlock(&_mutex);
}
void Pop(T& data)
{
pthread_mutex_lock(&_mutex);
while(_bq.empty())
{
pthread_cond_wait(&_consumer,&_mutex);
}
data=_bq.front();
_bq.pop();
pthread_cond_signal(&_producter);
pthread_mutex_unlock(&_mutex);
}
private:
queue<T> _bq;
int _capacity;//阻塞队列容量
pthread_mutex_t _mutex;
pthread_cond_t _consumer;
pthread_cond_t _producter;
};
//对线程进行封装
//T是临界资源的数据类型
template<class T>
class Pthread
{
public:
Pthread(function<void(T&)> func,T& data,string name)
:_func(func)
,_data(data)
,_pthread_name(name)
{}
~Pthread()
{}
static void* thread_func(void* arg)
{
Pthread<T>* p=reinterpret_cast<Pthread<T>*>(arg);
p->_func(p->_data);
return nullptr;
}
void Start()
{
if(-1==pthread_create(&_pthread_id,NULL,thread_func,this))
{
perror("pthread_create");
return;
}
}
void Wait()
{
pthread_join(_pthread_id,NULL);
}
void Detach()
{
pthread_detach(_pthread_id);
}
string GetPthreadName()
{
return _pthread_name;
}
private:
pthread_t _pthread_id;//线程ID
string _pthread_name;//线程名
T& _data;//临界资源
function<void(T&)> _func;//线程执行的函数
};
void Product(BlockQueue<int>& bq)
{
while(true)
{
int data=rand()%100;
bq.Push(data);
cout<<"producter: "<<pthread_self()<<" product:"<<data<<endl;
sleep(1);
}
}
void Consume(BlockQueue<int>& bq)
{
while(true)
{
int data=0;
bq.Pop(data);
cout<<"consumer: "<<pthread_self()<<" consume:"<<data<<endl;
sleep(1);
}
}
void StartProduct(vector<Pthread<BlockQueue<int>>>& pthreads,BlockQueue<int>& bq,int num)
{
while(num--)
{
pthreads.push_back(Pthread<BlockQueue<int>>(Product,bq,"producter"+to_string(num)));
pthreads.back().Start();
}
}
void StartConsume(vector<Pthread<BlockQueue<int>>>& pthreads,BlockQueue<int>& bq,int num)
{
while(num--)
{
pthreads.push_back(Pthread<BlockQueue<int>>(Consume,bq,"consumer"+to_string(num)));
pthreads.back().Start();
}
}
void WaitAllPthreads(vector<Pthread<BlockQueue<int>>>& pthreads)
{
int i=1;
for(auto& e:pthreads)
{
e.Wait();
}
}
int main()
{
srand(time(NULL));
vector<Pthread<BlockQueue<int>>> pthreads;
BlockQueue<int> bq(5);
StartProduct(pthreads,bq,3);
StartConsume(pthreads,bq,4);
WaitAllPthreads(pthreads);
return 0;
}
2.基于环形队列的生产者消费者模型
我们也可以使用环形队列来模拟“超市”这个场所,生产者生产数据放入环形队列后往后移动一个位置,消费者从环形队列消费一个数据后往后移动一个位置,我们需要保证生产者的位置始终在消费者前面或者与消费者位置相同。当消费者与生产者的位置不同时,由于他们访问的是不同的位置,此时他们可以并发操作;当消费者与生产者的位置相同时,队列可能为空,也可能为满,如果队列为空,必须让生产者先生产,如果队列为满,则必须让消费者先消费。
在实现基于环形队列的生产者消费者模型之前,我们需要先学习POSIX信号量的使用,POSIX信号量与System V信号量一样是一个计数器,可以用于同步操作,达到无冲突访问共享资源目的,其区别在于POSIX信号量除了用于进程间同步之外,还可以用于线程间同步。
1.初始化信号量:
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
用于初始化一个信号量,成功时返回0,出错时返回-1,并设置 errno 以指示错误类型,参数说明:
sem
:指向要初始化的信号量对象的指针。这是一个类型为 sem_t 的变量,用于存储信号量的状态。
pshared
:此参数决定了信号量的作用域。如果 pshared 的值为 0,则信号量仅在当前进程中可用,由该进程内的线程共享。如果 pshared 的值非 0(通常设为 1),则信号量将在多个进程间共享,这意味着它可以用于同步跨进程的操作。
value
:信号量的初始值
2.等待信号量:
#include <semaphore.h>
int sem_wait(sem_t *sem);
相当于P操作,让信号量计数减1,如果信号量计数已经为0,则线程或进程会被阻塞在这里,成功时返回0,出错时返回-1,并设置 errno 以指示错误类型,参数说明:
sem
:指向要初始化的信号量对象的指针。这是一个类型为 sem_t 的变量,用于存储信号量的状态。
3.发布信号量:
#include <semaphore.h>
int sem_post(sem_t *sem);
相当于V操作,让信号量计数加1,成功时返回0,出错时返回-1,并设置 errno 以指示错误类型,参数说明:
sem
:指向要初始化的信号量对象的指针。这是一个类型为 sem_t 的变量,用于存储信号量的状态。
4.销毁信号量:
#include <semaphore.h>
int sem_destroy(sem_t *sem);
成功时返回0,出错时返回-1,并设置 errno 以指示错误类型,参数说明:
sem
:指向要初始化的信号量对象的指针。这是一个类型为 sem_t 的变量,用于存储信号量的状态。
#include<iostream>
#include<vector>
#include<semaphore.h>
#include<pthread.h>
#include<unistd.h>
#include<functional>
using namespace std;
//循环队列
template<class T>
class RingQueue
{
public:
RingQueue(int CountOfSpace)
: _StepOfProducter(0)
,_StepOfConsumer(0)
{
_rq.resize(CountOfSpace);
sem_init(&_CountOfData,0,0);
sem_init(&_CountOfSpace,0,CountOfSpace);
pthread_mutex_init(&_producter,NULL);
pthread_mutex_init(&_consumer,NULL);
}
~RingQueue()
{
sem_destroy(&_CountOfData);
sem_destroy(&_CountOfSpace);
pthread_mutex_destroy(&_producter);
pthread_mutex_destroy(&_consumer);
}
void Push(T& data)
{
sem_wait(&_CountOfSpace);
pthread_mutex_lock(&_producter);
_rq[_StepOfProducter]=data;
_StepOfProducter=(_StepOfProducter+1)%_rq.size();
pthread_mutex_unlock(&_producter);
sem_post(&_CountOfData);
}
void Pop(T& data)
{
sem_wait(&_CountOfData);
pthread_mutex_lock(&_consumer);
data=_rq[_StepOfConsumer];
_StepOfConsumer=(_StepOfConsumer+1)%_rq.size();
pthread_mutex_unlock(&_consumer);
sem_post(&_CountOfSpace);
}
private:
vector<T> _rq;
sem_t _CountOfSpace;
sem_t _CountOfData;
pthread_mutex_t _producter;
pthread_mutex_t _consumer;
int _StepOfProducter;
int _StepOfConsumer;
};
//封装线程
template<class T>
class Pthread
{
public:
Pthread(function<void(T&)> func,T& data,string name)
:_func(func)
,_data(data)
,_pthread_name(name)
{}
~Pthread()
{}
static void* thread_fun(void* arg)
{
Pthread<T>* p=reinterpret_cast<Pthread<T>*>(arg);
p->_func(p->_data);
return nullptr;
}
void Start()
{
if(-1==pthread_create(&_pthread_id,NULL,thread_fun,this))
{
perror("pthread_create");
return;
}
}
void Wait()
{
pthread_join(_pthread_id,NULL);
}
void Detach()
{
pthread_detach(_pthread_id);
}
string GetPthreadName()
{
return _pthread_name;
}
private:
pthread_t _pthread_id;//线程ID
string _pthread_name;//线程名
T& _data;//临界资源
function<void(T&)> _func;//线程执行的函数
};
void Product(RingQueue<int>& rq)
{
while(true)
{
int data=rand()%100;
rq.Push(data);
cout<<"producter: "<<pthread_self()<<" product:"<<data<<endl;
sleep(1);
}
}
void Consume(RingQueue<int>& rq)
{
while(true)
{
int data=0;
rq.Pop(data);
cout<<"consumer: "<<pthread_self()<<" consume:"<<data<<endl;
sleep(1);
}
}
void StartProduct(vector<Pthread<RingQueue<int>>>& pthreads,RingQueue<int>& rq,int num)
{
while(num--)
{
pthreads.push_back(Pthread<RingQueue<int>>(Product,rq,"producter"+to_string(num)));
pthreads.back().Start();
}
}
void StartConsume(vector<Pthread<RingQueue<int>>>& pthreads,RingQueue<int>& rq,int num)
{
while(num--)
{
pthreads.push_back(Pthread<RingQueue<int>>(Consume,rq,"consumer"+to_string(num)));
pthreads.back().Start();
}
}
void WaitAllPthreads(vector<Pthread<RingQueue<int>>>& pthreads)
{
int i=1;
for(auto& e:pthreads)
{
e.Wait();
}
}
int main()
{
srand(time(NULL));
vector<Pthread<RingQueue<int>>> pthreads;
RingQueue<int> rq(5);
StartProduct(pthreads,rq,3);
StartConsume(pthreads,rq,4);
WaitAllPthreads(pthreads);
return 0;
}
读者写者问题
在编写多线程的时候,有一种情况是十分常见的。那就是有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。读写锁就是专门专门处理这种多读少写的情况。读者写者问题也可以简记为“321”原则:
3
:即3种关系:
- 写者 vs 写者:互斥
- 读者 vs 读者:没有关系
- 写者 vs 读者:互斥且同步
2
:即2种角色:
- 读者
- 写者
1
:即一个场所:
- 黑板(临界资源)
即读者可以并发读取数据,同时默认遵循读者优先原则(读者和写者同时来临时时,优先让读者先读取数据)
读写锁相关接口:
1.初始化读写锁
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
该函数执行成功时函数返回0,失败时返回错误码,参数说明:
rwlock
:指向要初始化的读写锁对象的指针。
attr
:指向读写锁属性对象的指针,该对象包含用于读写锁的属性。如果传递 NULL,则使用默认属性。
2.设置读写优先
#include<pthread.h>
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
需要注意该函数是一个非标准的(即不是 POSIX 线程标准的一部分),因此它的可用性、参数和返回值可能会因不同的系统和库版本而异。如果你正在编写可移植的代码,最好避免使用这个函数,除非你有特定的理由需要它,并且确信你的目标环境支持它。该函数执行成功时函数返回0,失败时返回错误码,参数说明:
attr
:指向读写锁属性对象的指针。
pref
:指定读写锁类型的偏好或类型,一般有以下选项:
- PTHREAD_RWLOCK_PREFER_READER_NP:读者优先,可能会导致写者饥饿情况,这是默认设置
- PTHREAD_RWLOCK_PREFER_WRITER_NP :写者优先,目前有存在问题
- PTHREAD_RWLOCK_PREFER_READER_NP :一致
- PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP: 写者优先,但写者不能递归加锁
3.加锁和解锁:
#include<pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
函数返回0,失败时返回错误码,参数说明:
rwlock
:指向读写锁对象的指针。
4.销毁读写锁:
#include<pthread.h>
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
函数返回0,失败时返回错误码,参数说明:
rwlock
:指向要销毁的读写锁对象的指针。
线程池
线程池是一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
线程池的应用场景:
-
需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
-
对性能要求苛刻的应用,比如要求服务器迅速响应客户请求
-
接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误
线程池示例:
- 创建固定数量线程池,循环从任务队列中获取任务对象,没有得到任务的线程暂时陷入休眠
- 获取到任务对象后,执行任务对象中的任务接口
#include<iostream>
#include<pthread.h>
#include<functional>
#include<queue>
#include<vector>
#include<time.h>
#include<unistd.h>
#include<string>
#include<stdarg.h>
using namespace std;
//用于日志功能中对显示器加锁保护
pthread_mutex_t log_lock = PTHREAD_MUTEX_INITIALIZER;
//日志功能
//日志等级
enum LogLevel
{
DEBUG=0,
INFO,
WARNING,
ERROR,
FATAL
};
//将日志等级转化为字符串
string LevelToString(int level)
{
switch (level)
{
case DEBUG:
return "Debug";
case INFO:
return "Info";
case WARNING:
return "Warning";
case ERROR:
return "Error";
case FATAL:
return "Fatal";
default:
return "Unknown";
}
}
//获取特定的时间格式
string GetTimeString()
{
time_t curr_time = time(nullptr);
struct tm *format_time = localtime(&curr_time);
if (format_time == nullptr)
return "None";
char time_buffer[1024];
snprintf(time_buffer, sizeof(time_buffer), "%d-%d-%d %d:%d:%d",
format_time->tm_year + 1900,
format_time->tm_mon + 1,
format_time->tm_mday,
format_time->tm_hour,
format_time->tm_min,
format_time->tm_sec);
return time_buffer;
}
void LogMsg(string filename,int line,int level,const char* format,...)
{
std::string levelstr = LevelToString(level);
std::string timestr = GetTimeString();
pid_t selfid = getpid();
char buffer[1024];
va_list arg;
va_start(arg, format);
vsnprintf(buffer, sizeof(buffer), format, arg);
va_end(arg);
std::string message = "[" + timestr + "]" + "[" + levelstr + "]" +
"[" + std::to_string(selfid) + "]" +
"[" + filename + "]" + "[" + std::to_string(line) + "] " + buffer + "\n";
pthread_mutex_lock(&log_lock);
cout << message;
pthread_mutex_unlock(&log_lock);
}
//宏定义
#define LOG(level, format, ...) \
do \
{ \
LogMsg(__FILE__, __LINE__, level, format, ##__VA_ARGS__); \
} \
while(0)
//线程封装
class Pthread
{
public:
Pthread(function<void()> func,string name)
:_func(func)
,_pthread_name(name)
{}
~Pthread()
{}
static void* thread_func(void* arg)
{
Pthread* p=reinterpret_cast<Pthread*>(arg);
p->_func();
return nullptr;
}
void Start()
{
if(-1==pthread_create(&_pthread_id,NULL,thread_func,this))
{
perror("pthread_create");
return;
}
}
void Wait()
{
pthread_join(_pthread_id,NULL);
}
void Detach()
{
pthread_detach(_pthread_id);
}
string GetPthreadName()
{
return _pthread_name;
}
private:
pthread_t _pthread_id;//线程ID
string _pthread_name;//线程名
function<void()> _func;//线程执行的函数
};
template<class T>
class ThreadPool
{
public:
ThreadPool(size_t threads_num=3)
:_threads_num(threads_num)
,_wait_num(0)
{
LOG(DEBUG,"thread pool init");
_tp.reserve(threads_num);
pthread_mutex_init(&_mutex,nullptr);
pthread_cond_init(&_cond,nullptr);
}
~ThreadPool()
{
LOG(DEBUG,"thread pool destroy");
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
void Thread_func()
{
LOG(DEBUG,"thread execute");
while(true)
{
pthread_mutex_lock(&_mutex);
while(_task.empty())
{
++_wait_num;
pthread_cond_wait(&_cond,&_mutex);
--_wait_num;
}
LOG(DEBUG,"thread get task");
T t=_task.front();
_task.pop();
pthread_mutex_unlock(&_mutex);
LOG(DEBUG,"thread starts to execute task");
t();
LOG(DEBUG,"thread completed task");
}
}
void Start()
{
LOG(DEBUG,"threads Start");
int i=0;
for(i=0;i<_threads_num;++i)
{
_tp.emplace_back(Pthread(bind(&ThreadPool::Thread_func,this),"Thread"+to_string(i)));
_tp.back().Start();
}
}
void PushTask(T t)
{
LOG(DEBUG,"PushTask");
pthread_mutex_lock(&_mutex);
_task.push(t);
pthread_cond_signal(&_cond);
pthread_mutex_unlock(&_mutex);
}
void Wait()
{
LOG(DEBUG,"thread wait");
for(auto& e:_tp)
{
e.Wait();
}
LOG(DEBUG,"thread end wait");
}
private:
queue<T> _task;//任务队列
vector<Pthread> _tp;//线程池
pthread_mutex_t _mutex;//用于对任务队列加锁保护
pthread_cond_t _cond;//线程在该条件变量下面等待任务分配
size_t _threads_num;//线程池中线程个数
size_t _wait_num;//休眠中的线程个数
};
void Task()
{
int a=rand()%100;
int b=rand()%100;
int sum=a+b;
string ret=to_string(a)+" + "+to_string(b)+" = "+to_string(sum);
cout<<ret<<endl;
}
int main()
{
srand(time(nullptr));
auto tp=ThreadPool<function<void()>>(5);
tp.Start();
int count=10;
while(count--)
{
tp.PushTask(Task);
sleep(1);
}
tp.Wait();
return 0;
}
实现以上代码一些需要注意的语法:
- 利用宏获取信息
__FILE__ //获取当前文件名
__LINE__ //获取当前行数
- 用于处理可变参数的宏
#include<stdarg.h>
void va_start(va_list ap, last_arg);
//获取可变参数,last_arg是可变参数...的前一个参数
//相当于ap获取到了可变参数第一个参数的位置
type va_arg(va_list ap, type);
//解析可变参数,之后ap自动指向下一个参数
void va_end(va_list ap);
//清理
- 宏定义减少用户传入参数
#define LOG(level, format, ...) LogMsg(__FILE__, __LINE__, level, format, ##__VA_ARGS__);
##__VA_ARDS__
用于接收可变参数,也可以将 ##__VA_ARDS__
替换成 __VA_ARDS__
,但在可变参数列表为空时 __VA_ARDS__
会有问题
- 使用
do while()
循环将宏定义成一个整体,保证宏的完整性
#define LOG(level, format, ...) \
do \
{ \
LogMsg(__FILE__, __LINE__, level, format, ##__VA_ARGS__); \
} \
while(0)
每一行后面需要加上续行符 \
,编译器可能会对此发出警告,不必在意
- 日志拥有一定的格式
日志等级 时间 当前文件名 当前行号 日志内容
线程安全的单例模式
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;
}
};
T
是我们希望的单例对象,同时我们需要注意以下几点:
- 需要设置
volatile
关键字,否则可能被编译器优化,编译器可能会将变量的值缓存到寄存器中,而不是每次访问都直接从内存中读取。对于单例对象的指针,如果编译器认为其值不会改变,就可能只在第一次访问时从内存中读取其值,之后就直接使用寄存器中的值。如果其他线程或外部代码修改了该指针的值,那么这种优化就会导致程序读取到错误的指针值。 - 使用互斥锁,保证多线程情况下也只生产一次对象
- 需要用双重判定空指针:外层判定是由于当对象已经创建时,线程就不必去申请锁解锁再去使用对象,从而提高效率,内层判定是由于当对象还没有创建时,可能会有多个线程申请锁去创建对象,
STL、智能指针与线程安全
STL中的容器不是是线程安全,因为STL的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响,而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶),因此 STL 默认不是线程安全,如果需要在多线程环境下使用往往需要调用者自行保证线程安全
对于智能指针 unique_ptr
,由于只是在当前代码块范围内生效,因此不涉及线程安全问题
对于 shared_ptr
,多个对象需要共用一个引用计数变量,所以会存在线程安全问题,但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证 shared_ptr 能够高效、原子的操作引用计数,故其引用计数是线程安全的,但对其指向的内存区域的读写操作以及修改 shared_ptr
对象本身的指向通常不是线程安全的。因此,在多线程环境中使用 shared_ptr
时,需要仔细考虑并采取适当的同步措施来确保线程安全。