c一、线程介绍
1.1、线程概念
线程是进程内的一个执行分支,线程的执行力度比进程更细。
进程是内核数据结构(tesk_struct) + 代码数据,而线程是进程内部的一个或者多个执行流,是一个程序内部的控制序列,一切进程都至少有一个执行线程,在linux系统中看到的pcb都比传统的进程更加轻量化,透过进程虚拟地址空间,可以看得到进程大部分资源,将进程资源合理分配给每个执行流就形成了线程执行流
1.2、线程优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多 能充分利用多处理器的可并行数量 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现 I/O密集型应用,为了提高性能,将I/O操作重叠。
- 线程可以同时等待不同的I/O操作。
1.3、线程缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型 线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的 同步和调度开销,而可用的资源不变 - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了 不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
1.4、线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该 进程内的所有线程也就随即退出
1.5、线程用途
合理的使用多线程,能提高CPU密集型程序的执行效率
合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是 多线程运行的一种表现)
二、进程和线程对比
2.1、进程和线程
进程是资源分配的基本单位,线程是调度的基本单位,线程共享进程数据,但是也**拥有一份自己的数据:**线程ID,一组寄存器,栈,errno,信号屏蔽字,调度优先级。
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中 都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境: 文件描述符表,每种型号的处理方式,当前工作目录,用户id和组id。
三、线程控制
3.1、POSIX线程库
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的 要使用这些函数库,要通过引入头文 链接这些线程函数库时要使用编译器命令的“-lpthread”选项
3.2、线程的创建
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
pthread_t *thread
:指向pthread_t
类型变量的指针,在线程创建成功后该变量将存储线程 ID。const pthread_attr_t *attr
:一个可选的指向pthread_attr_t
结构体的指针,该结构体包含新线程的各种属性,如栈大小、调度策略等。如果不需要指定任何属性,可以传递NULL
来使用默认属性。void *(*start_routine)(void *)
:指向新线程将要执行的函数的指针。这个函数应接受一个void*
参数并返回一个void*
。它代表新线程的入口点。void *arg
:当线程创建时,该参数将被传递给start_routine
函数。它可以用来传递线程函数所需的任何数据。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
void *threadRun(void *args)
{
while (1)
{
std::cout << "I am a thread !" << getpid() << std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t pid;
pthread_create(&pid, nullptr, threadRun, nullptr);
while (1)
{
std::cout << "main thread: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
在Linux中,目前的线程实现是Native POSIX Thread Libaray,简称NPTL。在这种实现下,线程又被称为轻量级进程(Light Weighted Process),每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描 述符(task_struct结构体)。
没有线程之前,一个进程对应内核里的一个进程描述符,对应一个进程ID。但是引入线程概念之后,情况 发生了变化,一个用户进程下管辖N个用户态线程,每个线程作为一个独立的调度实体在内核态都有自己的 进程描述符,进程和内核的描述符一下子就变成了1:N关系,POSIX标准又要求进程内的所有线程调用 getpid函数时返回相同的进程ID.
为了解决上述问题,Linux内核引入了线程组的概念。
struct task_struct {
...
pid_t pid;
pid_t tgid;
...
struct task_struct *group_leader;
...
struct list_head thread_group;
...
};
多线程的进程,又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符(task_struct) 与之对应。进程描述符结构体中的pid,表面上看对应的是进程ID,其实不然,它对应的是线程ID;进程描述符中的tgid,含义是Thread Group ID,该值对应的是用户层面的进程ID
现在介绍的线程ID,不同于pthread_t类型的线程ID,和进程ID一样,线程ID是pid_t类型的变量,而且是用来唯一 标识线程的一个整型变量。如何查看一个线程的ID呢?
ps -eLf |head -1 && ps -eLf |grep a.out |grep -v grep
//ps命令中的-L选项,会显示如下信息:
//LWP:线程ID,既gettid()系统调用的返回值。
//NLWP:线程组内线程的个数
Linux提供了gettid系统调用来返回其线程ID,可是glibc并没有将该系统调用封装起来,在开放接口来共程序员使 用。如果确实需要获得线程ID,Linux提供了gettid系统调用来返回其线程ID,可是glibc并没有将该系统调用封装起来,在开放接口来共程序员使 用。如果确实需要获得线程ID
#include <sys/syscall.h>
pid_t tid;
tid = syscall(SYS_gettid);
3.3、线程ID及进程地址空间布局
- pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是 一回事。
- 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要 一个数值来唯一表示该线程。
- pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于 NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
- 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:
#include <pthread.h>
pthread_t pthread_self(void);
3.4、线程终止
-
从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
-
线程可以调用pthread_ exit终止自己。
-
一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
#include <pthread.h>
void pthread_exit(void *retval);
//功能:线程终止
//原型
//void pthread_exit(void *value_ptr);
//参数
//value_ptr:value_ptr不要指向一个局部变量。
//返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
//pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
int pthread_cancel(pthread_t thread);
//功能:取消一个执行中的线程
//原型
//int pthread_cancel(pthread_t thread);
//参数
//thread:线程ID
//返回值:成功返回0;失败返回错误码
3.5、线程等待
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
//功能:等待线程结束
//参数
//thread:线程ID
//value_ptr:它指向一个指针,后者指向线程的返回值
//返回值:成功返回0;失败返回错误码
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。 创建新的线程不会复用刚才退出线程的地址空间。
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终 止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
- 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
std::string toHex(pthread_t tid)
{
char hex[64];
snprintf(hex, sizeof(hex), "%p", tid);
return hex;
}
void * threadRoutine(void*args)
{
while(1)
{
cout << "thread id:" << toHex(pthread_self()) << endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,threadRoutine,nullptr);
cout << "main thread create thread done, new thread id :" << toHex(tid) << endl;
pthread_join(tid, nullptr);
return 0;
}
3.6、进程分离
#include <pthread.h>
int pthread_detach(pthread_t thread);
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资 源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread_run(void *arg)
{
pthread_detach(pthread_self());
printf("%s\n", (char *)arg);
return NULL;
}
int main(void)
{
pthread_t tid;
if (pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0)
{
printf("create thread error\n");
return 1;
}
int ret = 0;
sleep(1); // 很重要,要让线程先分离,再等待
if (pthread_join(tid, NULL) == 0)
{
printf("pthread wait success\n");
ret = 0;
}
else
{
printf("pthread wait failed\n");
ret = 1;
}
return ret;
}
四、linux线程互斥
4.1、进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界自娱的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
4.2、互斥量引入(metux)
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个 线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之 间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
//售票系统代码,有并发问题
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define NUM 4
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 1000; // 用多线程,模拟一轮抢票
void *getTicket(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while (true)
{
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets); // ?
tickets--;
}
else
break;
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_datas;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
tids.push_back(tid);
}
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
如图所示,正常情况下在票为进程应该终止,但是最后的票却到了-2,这个是因为当if条件判断为真后可能会被切换到其他的线程,当线程被切换的时候会带走寄存器中存储的该线程的上下文信息,后续操作还没来得及完成,而第二个线程又执行一次if条件判断,然后被切换,第三个线程判断,然后执行完后续待会后切回,当线程切回后,其上下文信息是原有的,是从if判断进入之后开始执行,但是ticket是全局的,所有线程共享的,这时再继续减,就可能导致变成负数。
ticket-- 这个操作本身也不是一个原子操作
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临 界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
可以对其进行加锁操作,即添加互斥量
4.3、互斥量使用
//初始化互斥量
//初始化互斥量有两种方法:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER//静态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t*restrict attr);//动态分配
//参数:
//mutex:要初始化的互斥量
//attr:NULL
//销毁互斥量
//使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销毁
//不要销毁一个已经加锁的互斥量
//已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量的加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
//返回值:成功返回0,失败返回错误号
调用pthread_ lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
//改善后的售票系统代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define NUM 4
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 10; // 用多线程,模拟一轮抢票
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 定义全局互斥变量
void *getTicket(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while (true)
{
pthread_mutex_lock(&mutex);
if (tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets); // ?
tickets--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_datas;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
tids.push_back(tid);
}
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
4.4、可重入VS线程安全
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们 称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重 入函数,否则,是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生 死锁,因此是不可重入的。
4.5、LockGuard
std::lock_guard
是一个用于简化和保障互斥锁使用的 RAII(资源获取即初始化)类。它确保在对象的生命周期内持有一个互斥锁,并在对象销毁时自动释放该锁,从而避免忘记解锁和潜在的死锁问题。
这种方式确保了锁的获取和释放是异常安全的,即使在出现异常时也能正确地释放锁。这样可以有效防止死锁和其他同步问题。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void print_message(const std::string &message, int id) {
std::lock_guard<std::mutex> lock(mtx);
// 在锁的保护下进行输出操作
std::cout << "Thread " << id << ": " << message << std::endl;
}
void thread_function(int id) {
for (int i = 0; i < 5; ++i) {
print_message("Hello from thread", id);
}
}
int main() {
std::thread t1(thread_function, 1);
std::thread t2(thread_function, 2);
t1.join();
t2.join();
return 0;
}
在这个示例中,我们有两个线程,每个线程都在运行thread_function
函数。在 print_message
函数中,使用 std::lock_guard<std::mutex>
来锁定 mtx
互斥锁,这样可以确保两个线程不会同时进行输出操作,从而避免竞态条件。
4.5.1、自己实现
#pragma once
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t *lock):lock_(lock)
{}
void Lock()
{
pthread_mutex_lock(lock_);
}
void Unlock()
{
pthread_mutex_unlock(lock_);
}
~Mutex()
{}
private:
pthread_mutex_t *lock_;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *lock):mutex_(lock)
{
mutex_.Lock();
}
~LockGuard()
{
mutex_.Unlock();
}
private:
Mutex mutex_;
};
4.6、死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资 源而处于的一种永久等待状态。
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
避免死锁算法
- 死锁检测算法(了解)
- 银行家算法(了解)
五、Linux线程同步
5.1、条件变量
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情 况就需要用到条件变量。
同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问 题,叫做同步
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
5.2、条件变量函数引入
//条件变量函数 初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
//参数:
//cond:要初始化的条件变量
//attr:NULL
//销毁
int pthread_cond_destroy(pthread_cond_t *cond);
//等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
//参数:
//cond:要在这个条件变量上等待
//mutex:互斥量
//唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);//唤醒该条件变量下的所有线程
int pthread_cond_signal(pthread_cond_t *cond);//唤醒单个线程
代码示例:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
int cnt = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void *Count(void *args)
{
pthread_detach(pthread_self());
uint64_t number = (uint64_t)args;
std::cout << "pthread: " << number << " create success" << std::endl;
while (true)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex); // 1. pthread_cond_wait让线程等待的时候,会自动释放锁!
// 不管临界资源的状态情况
std::cout << "pthread: " << number << " , cnt: " << cnt++ << std::endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
for (uint64_t i = 0; i < 5; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, Count, (void*)i);
}
sleep(3);
std::cout << "main thread ctrl begin: " << std::endl;
while (true)
{
sleep(1);
//pthread_cond_signal(&cond); //唤醒在cond的等待队列中等待的一个线程,默认都是第一个
pthread_cond_broadcast(&cond);
std::cout << "signal one thread..." << std::endl;
}
return 0;
}
为什么pthread_ cond_ wait 需要互斥量?
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须 要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件 变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有 互斥锁就无法安全的获取和修改共享数据。
条件变量使用规范
//等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
//修改条件
pthread_mutex_unlock(&mutex);
//给条件发送信号代码
pthread_mutex_lock(&mutex);
//设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
5.3、生产者消费者模型
5.3.1、原则
生产者消费者模型(Producer-Consumer Model)是计算机科学中的一种常见设计模式,用于解决并发编程中的同步问题。它主要用于描述两个独立的进程或线程(生产者和消费者)在共享一个固定大小的缓冲区时如何协同工作。
- 生产者(Producer):
- 负责产生数据并将其放入缓冲区中。
- 如果缓冲区已满,生产者必须等待,直到缓冲区有空位。
- 消费者(Consumer):
- 负责从缓冲区中取出数据并进行处理。
- 如果缓冲区为空,消费者必须等待,直到有数据可用。
- 缓冲区(Buffer):
- 是生产者和消费者之间的共享资源,可以是一个队列或数组。
- 有固定的大小,用于存储生产者产生的数据,等待消费者处理。
- 同步机制:
- 为了防止竞态条件(即生产者和消费者同时访问缓冲区导致数据不一致),通常使用锁(Mutex)或信号量(Semaphore)来控制对缓冲区的访问。
- 条件变量(Condition Variable)也常用于管理生产者和消费者的等待和唤醒机制。
生产者消费者模型的工作流程
- 生产者流程:
- 生产者检查缓冲区是否已满。
- 如果缓冲区已满,生产者等待。
- 如果缓冲区未满,生产者产生一个数据项并将其放入缓冲区。
- 通知(唤醒)等待中的消费者。
- 消费者流程:
- 消费者检查缓冲区是否为空。
- 如果缓冲区为空,消费者等待。
- 如果缓冲区不为空,消费者从缓冲区取出一个数据项并进行处理。
- 通知(唤醒)等待中的生产者。
5.3.2、阻塞队列实现生产者消费者模型
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
template <class T>
class BlockQueue
{
static const int defaultnum = 1;
public:
BlockQueue(int maxcp = defaultnum)
:maxcp_(maxcp)
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&c_cond_, nullptr);
pthread_cond_init(&p_cond_, nullptr);
}
T pop()
{
pthread_mutex_lock(&mutex_);
while(q_.size() == 0)
{
pthread_cond_wait(&c_cond_,&mutex_);
}
T out = q_.front();
q_.pop();
pthread_cond_signal(&p_cond_);
pthread_mutex_unlock(&mutex_);
return out;
}
void push(const T& in)
{
pthread_mutex_lock(&mutex_);
while(q_.size() == maxcp_)
{
pthread_cond_wait(&p_cond_,&mutex_);
}
q_.push(in);
pthread_cond_signal(&c_cond_);
pthread_mutex_unlock(&mutex_);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&c_cond_);
pthread_cond_destroy(&p_cond_);
}
private:
std::queue<T> q_;
int maxcp_;
pthread_mutex_t mutex_;
pthread_cond_t c_cond_;
pthread_cond_t p_cond_;
};
#pragma once
#include <iostream>
#include <string>
std::string opers="+-*/%";
enum{
DivZero=1,
ModZero,
Unknown
};
class Task
{
public:
Task(int x, int y, char op) : data1_(x), data2_(y), oper_(op), result_(0), exitcode_(0)
{
}
void run()
{
switch (oper_)
{
case '+':
result_ = data1_ + data2_;
break;
case '-':
result_ = data1_ - data2_;
break;
case '*':
result_ = data1_ * data2_;
break;
case '/':
{
if(data2_ == 0) exitcode_ = DivZero;
else result_ = data1_ / data2_;
}
break;
case '%':
{
if(data2_ == 0) exitcode_ = ModZero;
else result_ = data1_ % data2_;
} break;
default:
exitcode_ = Unknown;
break;
}
}
void operator ()()
{
run();
}
std::string GetResult()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "=";
r += std::to_string(result_);
r += "[code: ";
r += std::to_string(exitcode_);
r += "]";
return r;
}
std::string GetTask()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "=?";
return r;
}
~Task()
{
}
private:
int data1_;
int data2_;
char oper_;
int result_;
int exitcode_;
};
#include "BlockQueue.hpp"
#include "Task.hpp"
#include <unistd.h>
#include <ctime>
pthread_mutex_t mutexp = PTHREAD_MUTEX_INITIALIZER;
void *Consumer(void *args)
{
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
while (true)
{
Task t = bq->pop();
t();
pthread_mutex_lock(&mutexp);
std::cout << "处理任务: " << t.GetTask() << " 运算结果是: " << t.GetResult() << " thread id: " << pthread_self() << std::endl;
pthread_mutex_unlock(&mutexp);
sleep(1);
}
}
void *Productor(void *args)
{
int len = opers.size();
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
while (true)
{
int data1 = rand() % 10 + 1; // [1,10]
usleep(10);
int data2 = rand() % 10;
char op = opers[rand() % len];
Task t(data1, data2, op);
// 生产
bq->push(t);
pthread_mutex_lock(&mutexp);
std::cout << "生产了一个任务: " << t.GetTask() << " thread id: " << pthread_self() << std::endl;
pthread_mutex_unlock(&mutexp);
//sleep(2);
}
}
int main()
{
srand(time(nullptr));
BlockQueue<Task> *bq = new BlockQueue<Task>();
pthread_t c[3], p[5];
for (int i = 0; i < 3; i++)
{
pthread_create(c + i, nullptr, Consumer, bq);
}
for (int i = 0; i < 5; i++)
{
pthread_create(p + i, nullptr, Productor, bq);
}
for (int i = 0; i < 3; i++)
{
pthread_join(c[i], nullptr);
}
for (int i = 0; i < 5; i++)
{
pthread_join(p[i], nullptr);
}
delete bq;
return 0;
}
5.4、信号量
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用 于线程间同步。
//初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
//参数:
//pshared:0表示线程间共享,非零表示进程间共享
//value:信号量初始值
//销毁信号量
int sem_destroy(sem_t *sem);
//等待信号量
//功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem);
//发布信号量
//功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);
5.4.1、环形队列
//RingQueue.hpp
#pragma once
#include <iostream>
#include <vector>
#include <semaphore.h>
#include <pthread.h>
const static int defaultcap = 5;
template<class T>
class RingQueue{
private:
void P(sem_t &sem)
{
sem_wait(&sem);
}
void V(sem_t &sem)
{
sem_post(&sem);
}
void Lock(pthread_mutex_t &mutex)
{
pthread_mutex_lock(&mutex);
}
void Unlock(pthread_mutex_t &mutex)
{
pthread_mutex_unlock(&mutex);
}
public:
RingQueue(int cap = defaultcap)
:ringqueue_(cap), cap_(cap), c_step_(0), p_step_(0)
{
sem_init(&cdata_sem_, 0, 0);
sem_init(&pspace_sem_, 0, cap);
pthread_mutex_init(&c_mutex_, nullptr);
pthread_mutex_init(&p_mutex_, nullptr);
}
void Push(const T &in) // 生产
{
P(pspace_sem_);
Lock(p_mutex_); // ?
ringqueue_[p_step_] = in;
// 位置后移,维持环形特性
p_step_++;
p_step_ %= cap_;
Unlock(p_mutex_);
V(cdata_sem_);
}
void Pop(T *out) // 消费
{
P(cdata_sem_);
Lock(c_mutex_); // ?
*out = ringqueue_[c_step_];
// 位置后移,维持环形特性
c_step_++;
c_step_ %= cap_;
Unlock(c_mutex_);
V(pspace_sem_);
}
~RingQueue()
{
sem_destroy(&cdata_sem_);
sem_destroy(&pspace_sem_);
pthread_mutex_destroy(&c_mutex_);
pthread_mutex_destroy(&p_mutex_);
}
private:
std::vector<T> ringqueue_;
int cap_;
int c_step_; // 消费者下标
int p_step_; // 生产者下标
sem_t cdata_sem_; // 消费者关注的数据资源
sem_t pspace_sem_; // 生产者关注的空间资源
pthread_mutex_t c_mutex_;
pthread_mutex_t p_mutex_;
};
//Task.hpp
#pragma once
#include <iostream>
#include <string>
std::string opers="+-*/%";
enum{
DivZero=1,
ModZero,
Unknown
};
class Task
{
public:
Task()
{}
Task(int x, int y, char op) : data1_(x), data2_(y), oper_(op), result_(0), exitcode_(0)
{
}
void run()
{
switch (oper_)
{
case '+':
result_ = data1_ + data2_;
break;
case '-':
result_ = data1_ - data2_;
break;
case '*':
result_ = data1_ * data2_;
break;
case '/':
{
if(data2_ == 0) exitcode_ = DivZero;
else result_ = data1_ / data2_;
}
break;
case '%':
{
if(data2_ == 0) exitcode_ = ModZero;
else result_ = data1_ % data2_;
} break;
default:
exitcode_ = Unknown;
break;
}
}
void operator ()()
{
run();
}
std::string GetResult()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "=";
r += std::to_string(result_);
r += "[code: ";
r += std::to_string(exitcode_);
r += "]";
return r;
}
std::string GetTask()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "=?";
return r;
}
~Task()
{
}
private:
int data1_;
int data2_;
char oper_;
int result_;
int exitcode_;
};
//main.cc
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <ctime>
#include "RingQueue.hpp"
#include "Task.hpp"
using namespace std;
struct ThreadData
{
RingQueue<Task> *rq;
std::string threadname;
};
void *Productor(void *args)
{
// sleep(3);
ThreadData *td = static_cast<ThreadData*>(args);
RingQueue<Task> *rq = td->rq;
std::string name = td->threadname;
int len = opers.size();
while (true)
{
// 1. 获取数据
int data1 = rand() % 10 + 1;
usleep(10);
int data2 = rand() % 10;
char op = opers[rand() % len];
Task t(data1, data2, op);
// 2. 生产数据
rq->Push(t);
cout << "Productor task done, task is : " << t.GetTask() << " who: " << name << endl;
sleep(1);
}
return nullptr;
}
void *Consumer(void *args)
{
ThreadData *td = static_cast<ThreadData*>(args);
RingQueue<Task> *rq = td->rq;
std::string name = td->threadname;
while (true)
{
// 1. 消费数据
Task t;
rq->Pop(&t);
// 2. 处理数据
t();
cout << "Consumer get task, task is : " << t.GetTask() << " who: " << name << " result: " << t.GetResult() << endl;
// sleep(1);
}
return nullptr;
}
int main()
{
srand(time(nullptr) ^ getpid());
RingQueue<Task> *rq = new RingQueue<Task>(50);
pthread_t c[5], p[3];
for (int i = 0; i < 1; i++)
{
ThreadData *td = new ThreadData();
td->rq = rq;
td->threadname = "Productor-" + std::to_string(i);
pthread_create(p + i, nullptr, Productor, td);
}
for (int i = 0; i < 1; i++)
{
ThreadData *td = new ThreadData();
td->rq = rq;
td->threadname = "Consumer-" + std::to_string(i);
pthread_create(c + i, nullptr, Consumer, td);
}
for (int i = 0; i < 1; i++)
{
pthread_join(p[i], nullptr);
}
for (int i = 0; i < 1; i++)
{
pthread_join(c[i], nullptr);
}
return 0;
}
5.5、读者与写者问题
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
/*
pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有 BUG,导致表现行为和
PTHREAD_RWLOCK_PREFER_READER_NP 一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
//初始化
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t
*restrict attr);
//销毁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
//加锁和解锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
5.6、线程池
一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个 线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不 仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内 存、网络sockets等的数量。
线程池的应用场景:
- 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使 用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于 长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大 多了。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没 有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程 可能使内存到达极限,出现错误.
#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <pthread.h>
#include <unistd.h>
struct ThreadInfo
{
pthread_t tid;
std::string name;
};
static const int defalutnum = 5;
template <class T>
class ThreadPool
{
public:
void Lock()
{
pthread_mutex_lock(&mutex_);
}
void Unlock()
{
pthread_mutex_unlock(&mutex_);
}
void Wakeup()
{
pthread_cond_signal(&cond_);
}
void ThreadSleep()
{
pthread_cond_wait(&cond_, &mutex_);
}
bool IsQueueEmpty()
{
return tasks_.empty();
}
std::string GetThreadName(pthread_t tid)
{
for (const auto &ti : threads_)
{
if (ti.tid == tid)
return ti.name;
}
return "None";
}
public:
static void *HandlerTask(void *args)
{
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
std::string name = tp->GetThreadName(pthread_self());
while (true)
{
tp->Lock();
while (tp->IsQueueEmpty())
{
tp->ThreadSleep();
}
T t = tp->Pop();
tp->Unlock();
t();
std::cout << name << " run, "
<< "result: " << t.GetResult() << std::endl;
}
}
void Start()
{
int num = threads_.size();
for (int i = 0; i < num; i++)
{
threads_[i].name = "thread-" + std::to_string(i + 1);
pthread_create(&(threads_[i].tid), nullptr, HandlerTask, this);
}
}
T Pop()
{
T t = tasks_.front();
tasks_.pop();
return t;
}
void Push(const T &t)
{
Lock();
tasks_.push(t);
Wakeup();
Unlock();
}
static ThreadPool<T> *GetInstance()
{
if (nullptr == tp_) // ???
{
pthread_mutex_lock(&lock_);
if (nullptr == tp_)
{
std::cout << "log: singleton create done first!" << std::endl;
tp_ = new ThreadPool<T>();
}
pthread_mutex_unlock(&lock_);
}
return tp_;
}
private:
ThreadPool(int num = defalutnum) : threads_(num)
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
~ThreadPool()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
}
ThreadPool(const ThreadPool<T> &) = delete;
const ThreadPool<T> &operator=(const ThreadPool<T> &) = delete; // a=b=c
private:
std::vector<ThreadInfo> threads_;
std::queue<T> tasks_;
pthread_mutex_t mutex_;
pthread_cond_t cond_;
static ThreadPool<T> *tp_;
static pthread_mutex_t lock_;
};
template <class T>
ThreadPool<T> *ThreadPool<T>::tp_ = nullptr;
template <class T>
pthread_mutex_t ThreadPool<T>::lock_ = PTHREAD_MUTEX_INITIALIZER;
六、线程安全的单例模式
单例模式是一种 “经典的, 常用的, 常考的” 设计模式.
IT行业这么火, 涌入的人很多. 俗话说林子大了啥鸟都有. 大佬和菜鸡们两极分化的越来越严重. 为了让菜鸡们不太拖 大佬的后腿, 于是大佬们针对一些经典的常见的场景, 给定了一些对应的解决方案, 这个就是 设计模式
单例模式的特点:
- 某些类, 只应该具有一个对象(实例), 就称之为单例
- 在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理 这些数据.
6.1、饿汉模式
饿汉模式(Eager Singleton)是一种单例设计模式的实现方式,用于确保一个类只有一个实例,并提供全局访问点。在饿汉模式中,单例实例在类加载时就创建,而不是在首次使用时创建。
特点
- 提前实例化:在类加载时就创建单例实例。
- 线程安全:由于实例在类加载时创建,因此是天然线程安全的,不需要额外的同步机制。
实现步骤
- 私有化构造函数:防止外部通过构造函数创建对象。
- 删除复制构造函数和赋值操作符:防止复制对象。
- 提供一个静态成员函数返回单例实例。
- 提供一个静态的单例实例成员。
#include <iostream>
// 单例类
class Singleton {
private:
// 私有化构造函数
Singleton() {
std::cout << "Singleton instance created." << std::endl;
}
// 禁止复制构造函数
Singleton(const Singleton&) = delete;
// 禁止赋值操作符
Singleton& operator=(const Singleton&) = delete;
// 静态实例对象
static Singleton instance;
public:
// 获取单例实例的静态成员函数
static Singleton& getInstance() {
return instance;
}
// 示例方法
void showMessage() {
std::cout << "Hello from Singleton!" << std::endl;
}
};
// 初始化静态成员
Singleton Singleton::instance;
int main() {
// 获取单例实例并调用方法
Singleton& singleton = Singleton::getInstance();
singleton.showMessage();
return 0;
}
解释
- 构造函数私有化:构造函数
Singleton()
是私有的,防止外部创建实例。 - 禁止复制和赋值:删除复制构造函数和赋值操作符,防止复制实例。
- 静态实例对象:
static Singleton instance;
是类的静态成员,在类加载时创建单例实例。 - 静态成员函数:
getInstance()
返回静态实例对象的引用,提供全局访问点。
优点
- 实现简单,不需要考虑多线程同步问题。
- 在类加载时创建实例,避免了多线程同步的开销。
缺点
- 如果单例类实例初始化过程复杂或占用资源较多,会延长类加载时间。
- 在某些情况下,如果单例实例不被使用,也会被创建,造成资源浪费。
饿汉模式适用于对资源消耗不敏感且需要确保线程安全的单例实现。对于一些更复杂的场景,可以考虑使用懒汉模式(Lazy Singleton)来延迟实例化。
6.2、懒汉模式
懒汉模式(Lazy Singleton)也是一种单例设计模式,与饿汉模式不同,它是在第一次使用时才创建实例,从而实现延迟加载。这种模式在需要控制实例化时机和减少内存占用的情况下非常有用。
特点
- 延迟实例化:在第一次使用时创建实例,而不是在类加载时。
- 线程安全性:需要在多线程环境下保证线程安全,通常需要额外的同步机制。
实现步骤
- 私有化构造函数:防止外部通过构造函数创建对象。
- 删除复制构造函数和赋值操作符:防止复制对象。
- 提供一个静态成员函数返回单例实例。
- 使用静态指针保存实例,并在首次调用时实例化。
#include <iostream>
#include <mutex>
// 单例类
class Singleton {
private:
// 私有化构造函数
Singleton() {
std::cout << "Singleton instance created." << std::endl;
}
// 禁止复制构造函数
Singleton(const Singleton&) = delete;
// 禁止赋值操作符
Singleton& operator=(const Singleton&) = delete;
// 静态指针保存单例实例
static Singleton* instance;
// 互斥量用于线程安全
static std::mutex mutex;
public:
// 获取单例实例的静态成员函数
static Singleton* getInstance() {
if (instance == nullptr) { // 双重检查锁定
std::lock_guard<std::mutex> lock(mutex);
if (instance == nullptr) {
instance = new Singleton();
}
}
return instance;
}
// 示例方法
void showMessage() {
std::cout << "Hello from Singleton!" << std::endl;
}
};
// 初始化静态成员
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;
int main() {
// 获取单例实例并调用方法
Singleton* singleton = Singleton::getInstance();
singleton->showMessage();
return 0;
}
解释
- 构造函数私有化:构造函数
Singleton()
是私有的,防止外部创建实例。 - 禁止复制和赋值:删除复制构造函数和赋值操作符,防止复制实例。
- 静态指针:
static Singleton* instance;
是类的静态成员,初始值为nullptr
,用于保存单例实例。 - 互斥量:
static std::mutex mutex;
用于保证多线程环境下的线程安全。 - 双重检查锁定:
getInstance()
方法中使用双重检查锁定(double-checked locking)来保证只有在instance
为nullptr
时才加锁,提高了效率。
优点
- 延迟加载:只有在第一次使用时才创建实例,节省内存和资源。
- 线程安全:使用互斥量和双重检查锁定保证线程安全。
缺点
- 实现复杂:相比饿汉模式,懒汉模式的实现更为复杂,需要考虑线程同步问题。
- 性能开销:加锁操作会带来一定的性能开销,尽管通过双重检查锁定已将其降到最低。
懒汉模式适用于实例创建开销较大且希望延迟到实际使用时再创建的场景。在实现时需要特别注意线程安全问题,确保在多线程环境下能够正确地创建和访问单例实例。
七、STL智能指针和线程安全
STL中的容器是否是线程安全的?
不是. 原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响. 而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶). 因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全.
智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题. 对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这 个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数.
八、其他常见的各种锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行 锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前, 会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等 则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁,公平锁,非公平锁?