线程池
用数组来管理多个线程,挨个存放线程的地址。具体代码详见我的gitee库
https://gitee.com/hepburn0504-yyq/linux-class/tree/master/2023_04_05_ThreadPool
线程池的结构定义为
template <class T>
class ThreadPool
{
private:
int _num; // 线程数
std::vector<Thread *> _threads;
std::queue<T> _task_queue; // 任务队列
pthread_mutex_t _mtx; // 锁
pthread_cond_t _cond; // 条件变量
};
单例模式
懒汉模式的核心思想是“延迟加载”。
new和malloc也是“延迟加载”!
缺页中断
进程调用malloc后,OS是否直接在物理内存开辟空间?
不是,进程申请空间并不一定立即使用(进行写入操作等),那这就浪费了这段物理内存,故OS先在虚拟地址上开辟一块空间给进程,当进程要往这块空间写入时,触发缺页中断,OS才会真正去开辟物理内存空间,重新构建物理内存和虚拟地址之间的映射关系。
缺页中断,就是当虚拟内存通过页表映射访问物理内存时,发现并没有这段物理内存就会触发缺页中断。让OS去开辟物理内存再重新构建映射关系。
注意:STL中的容器不是线程安全的!如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全。
所谓内存可见性问题,指的是多个线程同时操作一个变量,其中某个线程修改了变量的值之后,其他线程感知不到变量的修改,这就是内存可见性问题。 而使用 volatile 就可以解决内存可见性问题。
STL容器不是线程安全的
智能指针中,unique_ptr不涉及线程安全问题;shared_ptr存在线程安全问题,采用CAS的方式保证原子操作实现引用计数。
补充1:类中的静态成员访问关系
静态成员属于类而不是属于类对象
静态成员变量是一种特殊的成员变量,类体中的数据成员声明时前面加上关键字static,即成为该类的静态数据成员,即静态成员变量。静态成员变量实际上就是类域中的全局变量,必须初始化,且只能在类体外。初始化时不受private和protected访问限制。
static成员变量不占用对象内存,在所有对象外开辟内存,不创建对象也可以访问。
static成员变量和普通static变量一样,编译时在静态数据区分配内存,到程序结束时才释放。这就意味着static成员变量不随对象的创建而分配内存,也不随对象的销毁而释放内存。而普通成员变量在对象创建时分配内存,在对象销毁时释放内存。
static成员变量初始化不赋值会被默认初始化,一般是 0。静态数据区的变量都有默认的初始值,而动态数据区(堆区、栈区)的变量默认是垃圾值。
静态成员变量可以被自己所在类派生类的对象共享。
静态成员变量可以成为成员函数的可选参数,而普通成员变量不可以。
普通成员函数可以访问所有成员变量,而静态成员函数只能访问静态成员变量。
调用一个对象的非静态成员函数时,系统会把当前对象的起始地址赋给 this 指针。而静态成员函数并不属于某一对象,它与任何对象都无关,因此静态成员函数没有 this 指针。
**静态成员函数与非静态成员函数的根本区别是:有无this指针。**由此决定了静态成员函数不能访问本类中的非静态成员。
如果类的成员函数想作为回调函数来使用,一般情况下只能将它定义为静态成员才行。
补充2:单例模式volatile的问题
理论上来说,为了保持内存可见性,应该声明单例对象为volatile,让不同线程都能及时看到该单例对象的改变。
但是在实际应用中,我们不会给单例对象加该关键字,因为用volatile修饰的单例对象,只能调用volatile修饰的成员函数(构造、析构等等),在实际操作中比较麻烦。
注意ThreadPool类的成员设计
class Thread
{
public:
void start(func_t func, void *args = nullptr)
{//...
_func = func;
_args = args;//有ThreadPool的指针
int n = pthread_create(&_tid, nullptr, start_routime, this);
}
void *callback() { return _func(_args); }
//ThreadPool::static void *handlerTask(void *args); 把参数包给handlerTask函数
private:
static void *start_routime(void *args) // start_routime是类内成员,它的参数会有个this指针,要加个static确保只有1个参数
{//...
Thread *_this = static_cast<Thread *>(args);
return _this->callback();
}
}
template <class T>
class ThreadPool
{
public:
void run(){//...
thread->start(handlerTask, (void *)td);
}
private:
// 每个线程将要执行的routime函数
static void *handlerTask(void *args)
{
//从任务队列里取出任务
task = thread_data->_threadpool->pop();
}
private:
std::queue<T> _task_queue; // 任务队列
};
注意传参的细节:1、ThreadPool::run成员方法会调用Thread::start(),start函数先保存回调函数和参数包;2、Thread::start会调用pthread_create函数,给pthread_create函数传的参数是Thread自己类的指针(为了调用Thread::callback函数);3、pthread_create会调用Thread::start_routine函数;4、Thread::start_routine再调用Thread::callback把带有ThreadPool指针的参数包传下去;4、即调用ThreadPool::handleTask函数,相当于该函数虽然是静态的,但也拥有this指针可以访问类内成员_task_queue,可以执行取出任务的动作。
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
日志输出log.hpp
如何处理可变参数。可变参数在函数参数里写作(const char* format, ...)
1、va_
系列函数 挨个读取可变参数
头文件
#include <stdarg.h>
函数参数
void va_start(va_list ap, last);//初始化可变参数列表ap,必须要用。last表示第一个变量参数前的命名参数
type va_arg(va_list ap, type);//取出可变参数列表ap里的第一个参数,格式为last,用循环就是挨个取出
void va_end(va_list ap); //相当于ap=nullptr
void va_copy(va_list dest, va_list src);
2、v系列的c语言格式化输出函数
#include <stdarg.h>
int vprintf(const char *format, va_list ap);
int vfprintf(FILE *stream, const char *format, va_list ap);
int vsprintf(char *str, const char *format, va_list ap);
int vsnprintf(char *str, size_t size, const char *format, va_list ap);
vprintf的使用如下
logMsg(NORMAL, "%s: %d %c %.2f", "日志信息", 20230411, 'y', 15.51);
#define NORMAL 1
#define WARNING 2
#define ERROR 3 // 程序出错但不影响执行
#define FATAL 4 // 致命错误
// 日志信息:日志等级 日期 日志内容 支持用户自定义 (代码行数 用户)
void logMsg(int level, const char *format, ...)
{
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args); // 相当于ap = nullptr;
}
vsnprintf的使用如下
char logBuffer[1024];//自定义部分
va_list args;
va_start(args, format);
vsnprintf(logBuffer, sizeof logBuffer, format, args);
va_end(args); // 相当于ap = nullptr;
其他常见的各种锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
CAS伪代码
CAS(CompareAndSwap),是用来实现lock-free编程的重要手段之一,多数处理器都支持这一原子操作,其用伪代码描述如下
template bool CAS(T*addr,T expected,T value)//内存地址,备份的旧数据,新数据
{
if(*addr==expected){
*addr=value;
return true;
}
return false;
}
int count=0;
void count_atomic_inc(int*addr)
{
int oldval=0;
int newval=0;
do{
oldval=*addr;//备份旧数据
newval=old+1;//基于旧数据构造新数据
}until CAS(addr,oldval,newval)
}
自旋锁spinlock
自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态(互斥锁)。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。
读写锁rwlock
适用场景:某些公共数据的读频率比写频率高得多。
读写锁的行为:写独占,读共享,读锁优先级高
当前状态 | 读锁请求 | 写锁请求 |
---|---|---|
无锁 | 可以 | 可以 |
读锁 | 可以 | 阻塞 |
写锁 | 阻塞 | 阻塞 |
reader_count = 0;//全局数据,表示读者个数
//伪代码:读者
lock();
read_count++;
unclock();
//读取行为
lock();
read_count--;
unlock();
//伪代码:写者
lock();
if(read_count > 0)//读锁优先级更高{
unlock();
goto end;
}
//走到这说明此时没有读锁,就可以写入
//写入行为
unlock();
读者写者与生产消费的本质区别
生产消费模型:
- 3种关系:生产者与生产者之间是互斥关系,消费者与消费者之间也是互斥关系,生产者和消费者之间既互斥又同步;
- 2种角色:生产者线程、消费者线程;
- 1个交易场所:一段特定结构的共享缓冲区。
区别:1、**消费者会取走数据,但是读者不会取走数据。**所以消费者之间必须是互斥关系,读者之间无互斥关系。2、生产者和消费者地位尽可能对等,工作效率高;读者优先级更高,写者可能长期处于饥饿状态。