目录
- 一.线程概念
- 1.理解Linux下的线程
- 2.线程优缺点与异常
- 3.线程VS进程
- 4.再谈进程地址空间
- 二.线程控制
- 1.线程的创建使用
- 2.线程在进程地址空间的结构
- 3.初窥多线程
- 4.多线程抢票
- 三.线程互斥
- 1.进程线程间的互斥相关背景概念
- 2.互斥锁
- 四.线程安全
- 1.线程安全和可重入函数
- 2.死锁
- 3.线程同步
- 五.生产消费者模型
- 1.cp模型
- 2.基于阻塞队列实现cp
- 3.信号量
- 4.基于环形队列的生产消费者模型
- 5.线程池
- 六.周边
- 1.stl和智能指针
- 2.自旋锁和读写锁
一.线程概念
1.理解Linux下的线程
线程与我们之前学习的进程相关性很强:线程是进程内的一个执行流,一个进程可以有多个线程(执行流),所以常说线程的执行粒度比进程细。
例如上图所示大红框框柱的部分才是真正的进程:包括进程内多个执行流、以及自己的进程地址空间、映射的页表和在物理内存上被映射出的部分。
根据我们之前的学习,线程他肯定也是要有自己的结构体还描述的即:struct tcb,但是在Linux下并没有重新设计一个结构体,而是复用了进程的pcb—task_struct来模拟线程。
2.线程优缺点与异常
优点:
- 高效的资源利用:线程共享进程的地址空间和资源(如文件描述符、全局变量等),因此创建线程的开销远小于创建进程的开销。这种资源共享特性使得线程非常适合需要频繁切换或并发执行的场景。
- 并行处理能力:多个线程可以在多核CPU上实现真正的并行执行,从而显著提高程序的执行效率。在需要处理大量并发任务的应用中,如Web服务器、数据库系统,使用多线程能够有效减少响应时间。
- 简化通信:由于线程共享内存空间,它们可以通过共享变量直接通信,而不需要复杂的进程间通信机制(如消息队列、管道等)。这种直接通信的方式不仅简单,而且效率更高。
缺点:
- 复杂的同步机制:由于多个线程共享同一进程的内存空间,必须通过锁、信号量等同步机制来保护共享资源,避免竞态条件。这些同步操作增加了编程的复杂性,稍有不慎就可能引发死锁、活锁等问题。
- 调试困难:多线程程序的调试较为困难,尤其是在存在竞态条件、死锁或内存泄漏等问题时。线程之间的相互影响使得问题的重现性差,增加了问题定位和解决的难度。
- 异常处理难度大:由于线程共享进程的资源,如果一个线程发生异常且未被正确处理,可能会影响整个进程的稳定性。例如,线程崩溃可能导致共享资源的不一致,甚至使整个进程崩溃。为了确保程序的鲁棒性,必须为每个线程设计可靠的异常处理机制。
异常:
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
3.线程VS进程
线程是操作系统调度的基本单位,进程是资源分配的基本单位。线程比进程更加的轻量化:创建和释放更加轻量化、切换更加轻量化。同一进程下的线程切换比进程切换轻量级为不涉及内存映射的切换,也不需要重新加载缓存数据(因为线程共享同一进程的地址空间)。
4.再谈进程地址空间
页表的结构分为页目录和二级页表,以32位的机器为例,其实将32位的虚拟地址分为了10 10 12,其实前十位表示的十进制数表示页目录的下标,页目录表项存放着二级目录的地址,中间十位表示二级目录的下标,二级目录中存放着页框的起始地址,最后十二位则与页框的起始地址找到物理地址,即最后十二位表示偏移量。这也是X86的特点:起始地址+类型 = 起始地址 + 偏移量。
线程分配资源就是分配了进程内的地址空间。
二.线程控制
1.线程的创建使用
其实在内核中并没有明确的线程的概念,只给我们提供了轻量化进程的系统调用,则产生了一个在应用层封装了轻量化进程接口的第三方pthread库供用户使用。
pthread_t *thread: 是一个输出型参数,用来存储新创建线程的 tid 。pthread_t 是一个类型,用于唯一标识线程。这个标识符可以用于线程同步和控制(如 pthread_join)。
const pthread_attr_t *attr:用于指定线程的属性,nullptr设置为默认属性。
void *(*start_routine)(void *):是一个函数指针,该函数接受一个 void * 类型的参数并返回一个 void * 类型的值。线程开始执行时会调用这个函数。
void *arg:传递给线程起始例程的参数。它的类型是 void *,可以指向任何类型的数据。这个参数可以用来传递线程需要处理的数据,如若没有即传nullptr。
pthread_create()函数错误不设置错误码 会返回错误码,正常返回0。
下面是一个创建线程的简单演示:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;void* ThreadHod(void* arg)
{while(true){cout<<"new thread pid :"<<getpid()<<endl;sleep(1);}
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,ThreadHod,nullptr);cout<<"main thread ready pid is :"<<getpid()<<endl;usleep(1000);return 0;
}
可当我们编译时却发现代码出现了链接错误,不认识pthread_create这个函数。可是我们明明已经包了头文件,因为pthread是第三方库,包含了函数的声明、数据结构的定义以及宏的定义的头文件还要有库文件:
Makefile:
thread:thread.ccg++ -o $@ $^ -std=c++11 -lpthread.PHONY:clean
clean:rm -f thread
这样就能正常编译通过了:
我们可以观察到main主线程和创建的新线程的pid是一样的,这也说明他们属于同一个进程共用同一款进程地址空间。而在主线程短暂的休眠退出后,线程明明有死循环打印但是也跟着退出了。
我们再来修改下代码:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;void* ThreadHod(void* arg)
{while(true){cout<<"new thread pid :"<<getpid()<<endl;sleep(2);}
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,ThreadHod,nullptr);while(1){cout<<"main thread ready pid is :"<<getpid()<<endl;sleep(1);}return 0;
}
指令:ps -aL可以查看当前的所有的轻量化进程:
可以观察到同一PID下有两个线程(两个LWP),所谓LWP是线程的唯一标识符,可以理解为线程的"pid",那tid又是什么呢?我们后续再讲解。
线程和进程一样同样有线程等待的概念,已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
thread表示需要等待线程的tid
retval参数可传入一个二级指针,用来获取指向线程的返回值。
pthread_join默认是阻塞等待的。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;void* ThreadHod(void* arg)
{int cnt = 5;while(true){cout<<"new thread pid :"<<getpid()<<endl;cnt--;if(cnt==0)break;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,ThreadHod,nullptr);cout<<"main thread ready pid is :"<<getpid()<<endl;pthread_join(tid,nullptr);return 0;
}
可以看到主线程在打印完语句后并没有直接退出,而是在阻塞等待到线程结束才退出。
再来介绍几个关于线程的函数:
pthread_self返回当前线程的tid。
pthread_exit用来结束线程,retval用于指定线程退出时的返回值,用此函数退出的线程依旧需要用pthread_join等待回收。
thread是要取消线程的tid,用于请求取消某个线程的执行。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <string>
using namespace std;string conversion(pthread_t tid)
{char hex[64];snprintf(hex, sizeof(hex), "%p", tid);return hex;
}void* ThreadHod(void* arg)
{int cnt = 5;while(true){cout<<"new thread pid :"<<getpid()<<" ,tid is: "<<conversion(pthread_self())<<endl;cnt--;if(cnt==0)break;sleep(1);}pthread_exit((void*)999);
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,ThreadHod,nullptr);cout<<"main thread ready pid is :"<<getpid()<<" ,tid is: "<<conversion(pthread_self())<<endl;void* ret;pthread_join(tid,&ret);cout<<(long long int)ret<<endl;return 0;
}
上述代码用ret接收了线程pthread_exit设置的线程返回值。
2.线程在进程地址空间的结构
多个线程各自有一个栈,共用一个堆,线程没有独立的虚拟地址空间,只是在进程虚拟地址空间中拥有相对独立的一块空间,进程是资源的分配单位,所以线程并不拥有系统资源,而是共亨使用进程的资源,进程 的资源由系统进行分配,线程的概念是线程库给我们维护的,线程库不用维护执行流,原生线程库是要加载到内存中的(都是基于内存的)
3.初窥多线程
pthread_detach 和 pthread_join 是 POSIX 线程库中的两个重要函数,用于管理线程的生命周期。它们的主要区别在于如何处理线程结束后的资源管理和线程的同步。
pthread_detach 将线程设置为“分离”状态即分离线程,这意味着线程结束时,它的资源(如线程控制块)会被自动释放,而无需显式地调用 pthread_join。
根据上文的讲解后可以得知:用户级执行流:内核LWP = 1:1。
#include <iostream>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>using namespace std;int g_val =100;
const int NUM = 3;string conversion(pthread_t tid)
{char hex[64];snprintf(hex, sizeof(hex), "%p", tid);return hex;
}class threadData
{
public:threadData(int number){threadname = "thread-" + to_string(number);}public:string threadname;
};void *threadRoutine(void *args)
{pthread_detach(pthread_self());//分离线程threadData *td = static_cast<threadData *>(args);while (true){cout << "pid: " << getpid() << ", tid : "<< conversion(pthread_self()) << ", threadname: " << td->threadname<< ", g_val: " << g_val << " ,&g_val: " << &g_val <<endl;g_val++;sleep(1);}delete td;return nullptr;
}int main()
{// 创建多线程!vector<pthread_t> tids;for (int i = 0; i < NUM; i++){pthread_t tid;threadData *td = new threadData(i);pthread_create(&tid, nullptr, threadRoutine, td);tids.push_back(tid);}sleep(10); return 0;
}
每一个线程都会有自己独立的栈结构,其实线程和线程之间几乎没有秘密,线程的栈上的数据,也是可以被其他线程看到访问的。全局数据也是一样。
那如果想要一个私有的全局变量呢?
__thread 是一个用于实现线程局部存储(Thread-Local Storage, TLS)的修饰符。在多线程编程中,线程局部存储允许每个线程有自己独立的变量副本,而这些副本对其他线程不可见。这种机制对于线程之间需要独立数据的场景非常有用,例如线程的状态信息、缓存数据等。
__thread int g_val = 100;
__thread(编译选项)可以实现线程的局部存储,需要注意的是只能定义内置类型,不能用于自定义类型。
4.多线程抢票
现在我们想利用多线程来模拟一个抢演唱会票的场景:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>using namespace std;const int num = 5; // 线程数量
int tickets = 1000; // 总票数,模拟抢票的过程// 将线程 ID 转换为字符串表示
string conversion(pthread_t tid)
{char hex[64];snprintf(hex, sizeof(hex), "%p", tid);return hex;
}// 用于传递给线程的自定义数据类
class threadData
{
public:// 构造函数,初始化线程名称threadData(int number){threadname = "thread-" + to_string(number);}public:string threadname; // 线程名称
};// 抢票线程函数
void *getTicket(void *args)
{// 将传入的参数转换为 threadData 类型threadData *td = static_cast<threadData *>(args);const char *name = td->threadname.c_str(); // 获取线程名称while (true){if(tickets > 0) // 如果还有剩余票数{usleep(1000); // 模拟延迟,避免输出过快cout << "I am " << name << " tickets is " << tickets << endl;tickets--; // 票数减少}elsebreak; // 如果没有票,退出循环}// 线程结束时输出printf("%s ... quit\n", name);return nullptr;
}int main()
{vector<pthread_t> tids; // 用于保存线程IDvector<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); // 保存线程ID}// 等待所有线程结束for (auto thread : tids){pthread_join(thread, nullptr);}// 清理动态分配的线程数据对象for (auto td : thread_datas){delete td;}return 0;
}
我们用类threadData和容器vector存储线程tid和name,方便我们打印观察现象。
跑起来程序后一瞬间就把票抢完了,五个线程也都退出了。
可是票数竟然会出现零和负数,这是怎么一回事呢?
代码上的一句:
tickets--;
转换成汇编语句的话其实会是三句:
- 将内存中的tickets放到寄存器。
- 在cpu寄存器中进行减减操作
- 将计算结果些回内存
假设当线程1在进行完第一步后 可能会线程切换,导致出错,
eg:线程1将数据放到寄存器还没进行减减就切换线程,假设线程二从1000正常–到10,时间片到了线程切换会线程一,现在内存上是10,线程一再cpu寄存器上的1000–到999,再给到内存上成999出现错误。
本质上是多线程在访问共享资源的并发问题,想要解决就要对共享资源的任何访问,保证在任何时候都只会有一个执行流访问------也就是互斥。
三.线程互斥
1.进程线程间的互斥相关背景概念
- 临界资源:多是需要通过同步机制保护的共享资源,防止多个线程同时访问导致的并发问题。
- 临界区:访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完
成
2.互斥锁
互斥锁 可以看作是一种标志,它被锁定时,其他线程必须等待,直到锁被释放为止。这个机制可以用来保护临界区,确保多个线程不会同时进入同一段代码来操作共享资源。互斥锁的核心功能就是确保共享资源在某一时刻只能被一个线程访问。
我们先来了解关于互斥锁的接口:
上图为互斥锁的初始化、销毁
获取锁的接口:
pthread_mutex_lock尝试获取互斥锁。如果互斥锁已经被其他线程持有,则调用线程会被阻塞,直到锁可用。
pthread_mutex_trylock非阻塞获取互斥锁,如果锁已经被其他线程持有,该函数会立即返回,而不会使线程阻塞等待。
pthread_mutex_unlock释放互斥锁
下面我们把互斥锁加到抢票逻辑里:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>using namespace std;pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;const int NUM = 4;class threadData
{
public:threadData(int number ){threadname = "thread-" + to_string(number);}public:string threadname;
};int tickets = 5000; // 用多线程,模拟一轮抢票void *getTicket(void *args)
{threadData *td = static_cast<threadData *>(args);const char *name = td->threadname.c_str();while (true){pthread_mutex_lock(&lock); // 申请锁成功,才能往后执行,不成功,阻塞等待。if(tickets > 0){usleep(1000);printf("who=%s, get a ticket: %d\n", name, tickets); // ?tickets--;pthread_mutex_unlock(&lock);}else{pthread_mutex_unlock(&lock);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;}pthread_mutex_destroy(&lock);return 0;
}
互斥锁的创建可以全局/static创建并PTHREAD_MUTEX_INITIALIZER初始化,局部的使用pthread_mutex_init来初始化。
我们再跑起程序:
就正常完成了多线程抢票逻辑,并且没有出现票数为负数的情况。
可是我们观察到,怎么都是一个线程在抢票啊。
这里其实是不同线程对锁的竞争能力不同出现的,但又因为pthread_mutex_lock是阻塞获取锁的,其他线程就会一个尝试获取锁,长时间得不到锁导致的饥饿问题,这里我们必须让线程按照一定的顺序获取资源也就是我们后面要讲的同步。
加锁的表现:线程对临界区代码串行执行并发改串行 加锁本质:时间换安全 加锁原则:尽量保证临界区代码越少越好
锁本身是共享资源,但是锁的PV操作是原子的,那么他是怎么做到的呢:
锁的原子性
我们知道在临界区中线程可以被切换,在线程被切出去的时候,是持有锁的线程被切走的,我不在期间照样没有线程能拿到锁进入临界区访问临界资源,对于其他线程来说,一个线程要么没有锁,要么释放锁.所以当前线程访问临界资源,对于其他线程是原子的。
四.线程安全
1.线程安全和可重入函数
线程安全指的是一个函数或代码段在多线程环境下,即使被多个线程同时调用,也能够正确地执行而不会引发竞争条件或数据不一致性的问题。
- 线程安全 的函数可以在多线程环境中安全地执行,但不一定是可重入的。例如,使用互斥锁的函数在多线程环境中是安全的,但如果它依赖于静态数据或存在副作用,它可能不是可重入的。
- 可重入函数 是一种特殊类型的线程安全函数,能够在被中断后继续执行。因此,所有可重入函数都是线程安全的,但并非所有线程安全函数都是可重入的。
- 可重入函数是线程安全函数的一种,线程安全不一定是可重入的,而可重入函数则一定是线程安全的。如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
常见可重入情况:
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
2.死锁
死锁是指两个或多个进程或线程在执行过程中,由于竞争资源而造成的一种相互等待的状态,从而导致系统中所有相关进程或线程都无法继续执行。每个线程都在等待其他线程释放它所需的资源,但这些资源已经被其他线程持有,形成了一个封闭的循环等待。
通常死锁有四个必要条件:
- 互斥条件,一个资源只能被一个执行流使用
- 占有且等待(Hold and Wait):线程或进程已经持有一个资源,同时又请求其他资源,并且这些资源已经被其他线程或进程持有
- 非抢占(No Preemption):资源不能被强制从线程或进程中取走,必须由持有线程或进程自己释放。
- 循环等待(Circular Wait):若干执行流之间形成头尾相接的循环资源等待关系,其中每一个线程或进程都在等待下一个线程或进程释放它所需的资源。
那么我们想要解决死锁问题只要破坏四个必要条件其一即可。
3.线程同步
在之前我们讲到过纯互斥的场景下可能会因为线程间对锁的竞争力不同而导致饥饿问题,而线程同步机制就是为了解决这一问题,同步问题是在保证数据安全的情况下,让我们的线程具有一定的顺序性。
条件变量:
条件变量可以让多线程在访问临界资源时具有一定的顺序。
接口与互斥锁相似:
#include <iostream>
#include <unistd.h>
#include <pthread.h>const int num =5;int cnt = 0;
pthread_mutex_t lock = 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(&lock);pthread_cond_wait(&cond, &lock); std::cout << "pthread: " << number << " , cnt: " << cnt++ << std::endl;pthread_mutex_unlock(&lock);}
}int main()
{for(uint64_t i = 0; i < num; i++){pthread_t tid;pthread_create(&tid, nullptr, Count, (void*)i);usleep(1000);}sleep(3);std::cout << "main thread ctrl begin: " << std::endl;while(true) {sleep(1);pthread_cond_signal(&cond);std::cout << "signal one thread..." << std::endl;}return 0;
}
Count函数中,在上互斥锁之后,先让线程在条件变量队列中进行等待,模拟临界资源还未就绪的场景,pthread_cond_wait(&cond, &lock); 把线程放进队列中的同时还将锁给释放了,这样就能够避免饥饿问题。主线程则在合适的时机将条件队列中的线程唤醒。
运行起来就能看到线程在一个一个被唤醒。
五.生产消费者模型
1.cp模型
cp模型的321原则:
- 3种关系:生产者和生产者,消费者和消费者,生产者和消费者
- 2种角色:生产者和消费者
- 一个交易场所-特定的内存空间
在cp模型中有共享的资源以及若干线程,所以一定会有并发问题,所以我们需要维持:生产者和生产者互斥,消费者和消费者互斥,生产者和消费者互斥、同步。
cp模型的优点:
- 支持忙闲不均
- 生产者和消费者解耦
2.基于阻塞队列实现cp
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
阻塞队列的实现:
#pragma once#include <iostream>
#include <queue>
#include <pthread.h>template <class T>
class BlockQueue//阻塞队列
{static const int defalutnum = 20;
public:BlockQueue(int maxcap = defalutnum):maxcap_(maxcap){pthread_mutex_init(&mutex_, nullptr);pthread_cond_init(&c_cond_, nullptr);pthread_cond_init(&p_cond_, nullptr);}// 谁来唤醒呢?T pop(){pthread_mutex_lock(&mutex_);if(q_.size() == 0)//伪唤醒{pthread_cond_wait(&c_cond_, &mutex_); //重新持有锁}T out = q_.front(); q_.pop();pthread_cond_signal(&p_cond_); // pthread_cond_broadcastpthread_mutex_unlock(&mutex_);return out;}void push(const T &in){pthread_mutex_lock(&mutex_);if(q_.size() == maxcap_){ //伪唤醒pthread_cond_wait(&p_cond_, &mutex_); //1. 调用的时候,自动释放锁 2.?}// 1. 队列没满 2.被唤醒 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 maxcap_; // 极值pthread_mutex_t mutex_;pthread_cond_t c_cond_;pthread_cond_t p_cond_;};
在上面的代码中还有个伪唤醒的概念:
当线程走到pthread_cond_wait时会进到条件队列中等待并且释放锁,当生产者伪唤醒消费者线程时,可能多个线程同时被唤醒了,这就导致其中竞争力较强的拿到锁继续向下走,当此线程释放锁后,可以剩下两个之前同时被唤醒的线程其一再次竞争到锁,可是此时队列中已经没有数据了,导致代码往下走会出现错误。解决方法就是判断临界资源那里要将if改为while。
3.信号量
POSIX信号量和之前讲的SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。信号量的本质是一把计数器描述临界资源的数量,是保证原子性的PV计数器。
信号量的PV之间不用判断资源是否就绪,因为在申请时已经在做判断了。
4.基于环形队列的生产消费者模型
环形队列逻辑代码:
#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_;
};
5.线程池
当在 Linux 下实现线程池时,池化技术(Pooling)是一个重要的优化策略,它可以显著提高应用程序的性能和资源利用率。经典的以空间换时间的技术。线程池(Thread Pool)是一种预先创建多个线程并将其保留在池中,供多个任务重用的技术。通过线程池,我们可以避免频繁的线程创建和销毁,降低系统开销,提高程序的响应速度。池化技术的核心是复用资源。在线程池中,线程并不会在每次任务完成后立即销毁,而是返回到池中待命,以便处理后续的任务。
在 Linux 下,线程池的实现通常包括以下几个核心组件:
- 线程管理:线程池需要管理线程的创建、销毁和调度。通常使用 POSIX 线程库(pthread)来创建和管理线程。
- 任务队列:线程池维护一个任务队列,任务由生产者线程添加到队列中,由线程池中的工作线程从队列中取出并执行。
- 同步机制:为了确保线程安全,线程池需要使用互斥锁(mutex)和条件变量(condition variable)来同步对任务队列的访问和线程的状态管理。
使用懒汉模式的线程池逻辑:
#pragma once#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <pthread.h>
#include <unistd.h>// 线程信息结构体,包含线程ID和线程名称
struct ThreadInfo
{pthread_t tid; // 线程IDstd::string name; // 线程名称
};// 默认线程池中的线程数量
static const int defalutnum = 5;// 线程池模板类,T 是任务类型
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();}// 根据线程ID获取线程名称std::string GetThreadName(pthread_t tid){for (const auto &ti : threads_){if (ti.tid == tid)return ti.name;}return "None"; // 如果找不到对应的线程,返回 "None"}public:// 处理任务的静态方法,每个线程执行的函数static void *HandlerTask(void *args){// 将传入的参数转换为ThreadPool对象指针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;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;
其中需要注意的是之所以将HandlerTask设置成静态函数,因为pthread_create的参数void *(*start_routine)(void *)要求了只能有一个参数,当HandlerTask作为类的成员函数时,会有隐藏的参数this指针。获取单例对象的接口双重判断空指针,降低冲突概率。
线程池是与cp模型类似的。
六.周边
1.stl和智能指针
stl是否线程安全:
不是.原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全
智能指针:
- unique_ptr 是线程安全的,前提是它不在多个线程中共享使用。
- shared_ptr 的引用计数更新是线程安全的,但在多个线程中访问和修改同一个shared_ptr 实例时需要额外的同步机制。
- weak_ptr 的操作在大多数情况下是线程安全的,但从weak_ptr 升级为shared_ptr 时要注意线程安全。
2.自旋锁和读写锁
在Linux系统中,读写锁是一种同步原语,它允许多个线程同时读取共享数据,但在写操作时,线程必须独占该资源。相比于互斥锁,读写锁在读操作频繁且写操作较少的场景下,可以提高系统的并发性能。
读写锁的模型和cp一样有321原则:
- 3种关系,读读共享,写写互斥,读写互斥同步
- 2个角色,读者写者
- 1个交易场所,数据交换的地方
- 读锁(共享锁):多个线程可以同时持有读锁,这意味着多个线程可以同时读取共享数据而不互相阻塞。读锁之间是共享的,但读锁与写锁是互斥的。
- 写锁(独占锁):只有一个线程可以持有写锁,并且持有写锁时,其他任何读锁或写锁都将被阻塞。写锁之间是互斥的。
- 默认行为----读者优先,也有写者优先–先等读者读完优先让写者先进去策略
- 读读共享而cp的消费者之间是互斥是因为读者不会把数据拿走
自旋锁:
自旋锁是一种忙等待锁,它在尝试获取锁时,如果锁已被其他线程持有,当前线程不会进入睡眠或被挂起,而是会在循环中不断地检查锁的状态,直到锁可用。自旋锁适用于锁持有时间较短的情况。
要不要使用自旋锁就看线程在临界资源的时长。
- 普通自旋锁:基本的自旋锁,线程在获取锁时会忙等待直到获取锁成功。
- 嵌套自旋锁:允许同一线程多次获取锁而不导致死锁。这种锁记录了获取锁的次数,在解锁时需要对应的解锁次数。
- 递归自旋锁:与嵌套自旋锁类似,递归自旋锁允许同一个线程多次获取同一把锁,只需在最后一次解锁时释放锁。
挂起等待锁
挂起等待锁是一种在获取锁失败时将线程挂起的锁。与自旋锁不同,当线程无法获取锁时,它不会忙等待,而是进入睡眠状态,等待被唤醒。这种锁适用于锁持有时间较长的场景,以减少CPU的空转消耗。
分为互斥锁、读写锁、信号量、条件变量。