目录
信号量
信号量是什么
信号量的操作
基于环形队列的生产者消费者模型
我们想怎么写这个模型
线程池
线程池的实现
线程的属性及方法
线程池的属性及方法
主线程
信号量也是通信的一种,前面在进程间通信的时候,我们说信号量到线程的时候会说,现在我们正式的谈一下信号量。
信号量是一种通信机制,在进程间通信可以使用,在线程中也可以使用,但是信号量并不像管道那样,用于数据的传输,而是信号量是用于访问控制的没在多线程或者多进程中,如果没有访问控制的话,那么通信有时候就会有问题。
也就是当我向发给你 123456789 这个电话号码时,我刚发了1234 你就拿到了,但是这个数据你用不了,因为我想给你发都是一个整体,所以就需要访问控制。
那么现在我们先知道信号量是一个数据通信时候,用于访问控制的一个机制,那么下面我们继续说一下,信号量。
信号量的本质类似一把计数器!!!
下面我们来距离说一下这个信号量:在前面的多线程中,不论是基于阻塞队列的生产者消费者模型中,还是前面的抢票。我们发现,我们都将临界资源作为一个整体使用。但是实际上,有一些临界资源是可以被划分的。
下面我们用电影院举例子:
现在我们要去看一场电影,我们现在以及买好一张票了,那么这意味着我们到时候区看这场调用的时候,一定有一个座位是我的,那么即使我们开始的时候没有去,但是我们电影即使是快结束了,那么我去了的话,这场电影还是有我的一个座位。
那么这个就是预定,而信号量也是这样,因为临界资源有时候可以被划分为多分资源,每个人都访问的是不同的位置,如果所有的位置都有人在访问,那么其他人才访问不了,如果临界资源里面的资源没有被使用完,那么就可以访问。
而我们每个人都像拿线程一样,而电影院中的座位,就是临界资源被划分后的小资源,而电影票就是信号量,所以这里说信号量类似于一把计数器。
所以这里只需要记住一个结论:信号量类似一把计数器。
既然我们知道信号量是什么,也知道信号量是干什么的,下面看一下信号量的操作:
信号量和前面的锁(mutex)、条件变量(cond),其实基本操作都是一类似的。
首先是信号量的初始化:
NAME
sem_init - initialize an unnamed semaphore
SYNOPSIS
#include
int sem_init(sem_t *sem, int pshared, unsigned int value);
Link with -pthread.
这里的信号量的第一个参数就是一个 sem_t 的一个指针,类似于锁的 pthread_mutex_t。
第二个参数表示共享:如果是0的话,那么表示只在该进程内有效,其他的进程看不到,如果是非0的话,那么就表示共享,一般都设置为0即可。
第三个参数,如果还记得的话,那么前面我们是说过一个信号量的结论的,就是信号量的本质类似一把计数器,那么这把计数器当然要有值,所以这个参数就是用于初始化计数器的。
信号量的销毁:
NAME
sem_destroy - destroy an unnamed semaphore
SYNOPSIS
#include
int sem_destroy(sem_t *sem);
Link with -pthread.
这个函数不需要介绍,这就是一个信号量初始化后需要销毁的函数。
信号量的P操作:
NAME
sem_wait, sem_timedwait, sem_trywait - lock a semaphore
SYNOPSIS
#include
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
Link with -pthread.
这个就是信号量的申请,前面不是说了,信号量的本质就是一把计数器,所以信号量的申请就可以理解为对这把计数器进行减减操作。
而对信号量的申请,又叫做P操作!
那么上面这三个函数分别有什么意思?
第一个函数就是表示对信号量进行申请,如果此时计数器为0,那么还要申请信号量,此时就会阻塞,类似于竞争锁一样,而信号量可以是任意数,而锁只有0~1,也就是类似于锁是计数器只有1的信号量,如果还有信号量,那么此时就信号量申请成功,就可以去访问临界资源了。
第二个函数也是申请信号量,其实这个我们也在锁的时候看见过类似的,pthread_mutex_trylock,也是类似的效果,如果没有信号量,此时直接返回,不进行阻塞,所以这里也就可以缺点,锁其实就是只有一个计数器的信号量没问题。
第三个函数,这个函数多了一个 time 的参数,其实我们知道了上面两个函数,这个函数都能猜到是什么作用,一定是在传入的时间范围内阻塞,超出范围就返回。
信号量的V操作:
NAME
sem_post - unlock a semaphore
SYNOPSIS
#include
int sem_post(sem_t *sem);
Link with -pthread.
这个函数就类似于锁里面的 unlock 函数,这个而前面也说了锁也可以理解为只有1的计数器,那么对锁的unlock,也就可以为对这把锁在加加,而lock表示对这把锁减减,那么这个函数就是对信号量计时器进行加加。
而这个操作就是对信号量的V操作。
看了前面的这些函数,我们看到下面都有一个 link with_pthread 的一句话,说明这些函数是需要 pthread 库的,所以使用的时候,也需要链接 pthread 的库。
我们想基于环形队列来写这个,那么我们还需要什么呢?我们还需要就是使用信号量,因为环形队列里面大多数时候,一定有很多资源,不论是空位置(生产者可以生产),还是数据(消费者可以消费),这些都是资源。
那么也就意味着如果将整个环形队列看作一个整体,那么一次只有一个线程可以进入访问资源,那么就会导致效率低下,那么可以将整个环形队列看作多个资源,可以让多个线程进入,那么也就可以提高效率。
那么对于生产者而言,我们需要关注哪些资源呢?
对于生产者而言,我们需要关注的是空的位置,也就是没有数据的位置。
刚开始的时候,环形队列的起始位置和终止位置一定是在一起的,我们认为end位置指向最后一个元素的下一个位置,所以当环形队列为空的时候,和环形队列为满的时候,他的 start 和 end 一定是在一起的,而当环形队列为空,或者为满,那么此时生产者和消费者分别要做什么?
这里先说生产者,当环形队列为空的时候,那么生产者刚好需要的是空位置用于存放生产好的资源,所以这时候生产者是可以生产的,那么当队列为满的时候,生产者应该怎么做呢?当队列为满的时候,此时是没有空位置的,那么此时生产者是不能进行生产的,而是应该让消费者来消费。
而当既有空位置又有数据的时候,那么此时生产者和消费是访问的是不同的位置,那么此时生产者和消费者此时就是并发的。
对于消费者而言,消费者需要关注的是数据资源。
那么当环形队列刚开始的时候,是没有数据的,那么当消费者想要消费的时候,一旦申请了信号量,那么就会阻塞住,所以此时一定是生产者来生成,当生产者生产资源后,那么就说明现在以有了数据资源,也就是可以让数据的信号量进行加加操作,也就是V操作,而当消费者在消费后,那么此时一定是有空位产生的没那么此时消费者也就可以让空位置的信号量加加也就是空位置的V操作。
那么当消费者申请一个资源后,那么数据资源的信号量怎么办呢?当消费者申请一个信号量的时候,说明此时的环形队列里面一定会有一个数据是可以被这个消费者所访问的,那么如果我们不访问这个数据呢?不访问也关系,我们可以后面去访问,即使后面去访问,那么一定还是有一个数据是属于我们的,那么当我们申请资源后,那么此时的数据的信号量一定是需要减减操作的。
现在我们在结合锁说一下信号量:
之前我们在使用锁的时候,如果是多线程的条件下,锁只能保证临界资源的安全,并不能保证合理性,前面我们说过,需要访问临界资源就需要先对临界资源检测,看是否存在,而之前我们检测到不存在的时候,我们就需要条件变量来控制,如果不满足就阻塞在条件变量里面,如果当条件满足了就唤醒。
但是我们这里可没有主动的去检测临界资源是否满足,因为这里我们其实以及检测了,那就是使用信号量,而信号量的计数器就表示资源的数量,如果资源的数据剩余0,那么此时继续申请信号量此时就会判断到条件不满足,此时就会进行阻塞,也就类似于我们判断到条件不满足后,我们调用 pthread_cond_wait 操作,如果此时以及条件满足的了,那么此时我们调用 pthread_cond_signal/pthread_cond_broadcast 函数就类似于调用 sem_post 函数。
所以其实我们也以及检测过条件是否满足了,而不满足后也是阻塞。
下面我们在说一下关于这个代码里面的需要的成员变量:
首先就是我们所有线程都访问的是一个环形队列,那么为什么能划分为小资源呢?为什么可以让生产者和消费者可以同时访问不同的资源呢?任意一方没有资源的话,那么又是如何做到让生产者和消费者的互斥的呢?因为我们生产者想要访问的是数据资源,而消费者想要访问的是空位置资源,如果没有资源,那么也就是 start == end 说明一定是资源要么为空要么为满,所以此时不论生产者还是消费者一定是有一方会阻塞的,所以信号量就保证了生产者者和消费者之间一定会访问不同的资源,也会在特定的时候会互斥,也会同步。那么既然是访问不同的资源,那么我们一定需要两个位置用来标记生产者和消费者想要访问的不同资源。
我们在前面谈到了生产者关注的是空位置资源,消费者关注的是数据资源,所以我们一定至少需要两个条件变量,用来维护这两个资源的个数,而刚开始的时候数据资源是为0的,也就是当刚开始消费者就想消费的时候,是不成功的,而空位置资源的个数就取决于环形队列的大小了,也就是刚开始是可以生产的。
那么我们的生产者消费者模型一定是多线程的,我们目前已经处理好生产者和消费者之间的关系,但是当多个生产者或者多个消费者同时进入临界资源想要生产或者消费数据呢?所以此时我们一定是需要锁的,需要用锁来维护生产者和生产者之间的互斥,也就是每次只能有一个生产者进入临界资源访问,还有就是消费者和消费者之间的互斥,一次也只能有一个消费者进入消费,那么此时我们需要几把锁呢?如果我们只有一把锁,那么就是生产者消费者共用这把锁,也就是一次只能由一个线程进入临界资源,那么就相当于是阻塞队列,所以不能只有一把锁,那么也就是我们也是需要至少两把锁,一把是用与消费者与消费者,一把是用于生产者和生产者的。
基于环形队列的生产者消费者模型代码:
成员变量:
template
class ringQueue
{
public:
ringQueue(int size)
:_ring_queue(size)// 初始化环形队列的大小,使用数组模拟环形队列
,_consum_sem(0)// 初始化消费者信号量
,_product_sem(size)// 初始化生产者信号量
,_consum_step(0)// 初始化消费者起始位置
,_product_step(0)// 初始化生产者起始位置
{}
private:
std::vector _ring_queue;// 环形队列
sem _consum_sem;// 将信号量封装
sem _product_sem;
mutex _consum_lock;// 将锁封装
mutex _product_lock;
int _consum_step;// 标记消费者位置
int _product_step;// 标记生产者位置
};
// 封装信号量
class sem
{
public:
sem(int n)
{
sem_init(&_semid, 0, n);
}
~sem()
{
sem_destroy(&_semid);
}
void p()
{
sem_wait(&_semid);
}
void v()
{
sem_post(&_semid);
}
private:
sem_t _semid;
};
//封装锁
class mutex
{
public:
mutex()
{
pthread_mutex_init(&_mtx, nullptr);
}
~mutex()
{
pthread_mutex_destroy(&_mtx);
}
void lock()
{
pthread_mutex_lock(&_mtx);
}
void unlock()
{
pthread_mutex_unlock(&_mtx);
}
private:
pthread_mutex_t _mtx;
};
下面我们环形队列还需要对数据进行添加的操作:
所以我们需要一个函数用于对数据向环形队列中添加
void push(const T& date)
{
// 如果是多线程条件下,那么需要先加锁
// 那么加锁怎么加呢?加到哪里呢?,应该先申请信号量,还是先加锁呢?
// 放数据的时候,首先应该判断是否有空余位置
_product_sem.p();// 申请信号量,如果申请到了,那么就可以继续向下走,如果没有信号量,那么阻塞
_product_lock.lock();// 这里说明一定是有属于这个线程的一个资源的,那么由于是多线程,需要加锁老保护生产者的这部分资源,因为不光有一个生产者。
_ring_queue[_product_step++] = date;
_product_lock.unlock();// 访问后就解锁
_consum_sem.v();// 如果已经生产好了,那么一定是有一个数据资源的,此时就需要对数据资源进程加加操作
}
那么还需要一个用于对消费者拿数据的一个函数:
void pop(T* date)
{
// 拿数据的时候,需要先查看是否有数据
_consum_sem.p();// 先申请数据资源
_consum_lock.lock();// 访问数据资源前先加锁,因为不光有一个线程访问该资源
*date = _ring_queue[_consum_step++];
_consum_lock.unlock();
_product_sem.v();// 访问后,那么一定是一个数据被消费了,那么一定就有一个空的资源产生了
}
所以我们的循环队列只有这么点代码,下面我们看一下主函数的逻辑:
主函数里面,我们只需要创建多线程,出让一部分线程执行生产者的代码,一部分执行消费者的代码即可,然后也可以 join 其他的线程,也可以直接 detach 其他线程,然后不要 join,但是一定要保证主线程不要退出,如果退出的话,那么其他的线程也就跟着退出了。
const int CONSUM_NUM = 4;
const int PRODUCT_NUM = 4;
void *consumer(void *args)
{
ringQueue *rqueue = (ringQueue*)args;
pthread_detach(pthread_self());
while(true)
{
int date = 0;
usleep(1000);
rqueue->pop(&date);
usleep(1000);
cout << "消费者: [" << pthread_self() << "]消费了一个数据: " << date << endl;
sleep(1);
}
}
void *producter(void *args)
{
ringQueue *rqueue = (ringQueue*)args;
pthread_detach(pthread_self());
while(true)
{
int date = rand() % 100 + 1;
cout << "生产者: [" << pthread_self() << "]生产了一个数据: " << date << endl;
// usleep(1000);
rqueue->push(date);
// usleep(1000);
// sleep(1);
}
}
void crtConsum(void *args)
{
pthread_t tid;
for (int i = 0; i < CONSUM_NUM; ++i)
pthread_create(&tid, nullptr, consumer, args);
}
void crtProduct(void *args)
{
pthread_t tid;
for (int i = 0; i < PRODUCT_NUM; ++i)
pthread_create(&tid, nullptr, producter, args);
}
int main()
{
srand(time(0));
ringQueue *rqueue = new ringQueue(5);
crtConsum(rqueue);
crtProduct(rqueue);
while(true) sleep(100);
delete rqueue;
return 0;
}
在这之前先介绍一下池的概念,一般的池是为了什么呢?
池就是为了提高效率,那么如何提高效率呢?
假设我们现在有很多任务,所以需要很多线程来执行但是此时是没有线程的,所以需要先创建线程,那么创建线程需要消费系统资源吗?需要,虽然说创建线程的消耗没有创建进程的消耗高,但是如果一次性创建大量的线程也是很消耗系统资源的,所以为了应对这个情况,我们可以提前创建一批线程,这样即使突然有大量的任务到达,也可以不需要创建大量的线程,这样就可以提高效率,因为创建线程也是需要消耗时间的,所以这样就可以节省每次创建线程的时间,当没有任务的时候,就可以让这些线程等待。
下面说一下线程池准备如何实现:
我们的线程池,想让主线程获取任务,然后将任务放到线程池的队列中,然后让创建好的线程去处理这些任务,而我们发现这是什么?这不就正是我们前面写的生产者消费者模型吗?
下面线程的实现中,使用了一下其他的头文件,但是这些头文件里面的内容就不展示出来了,想要看的可以去git 里面去看:使用的其他头文件有 log.hpp(日志) task.hpp(让线程处理的任务)
[线程池代码] https://gitee.com/naxxkuku/linux/tree/master/threadPool
既然我们需要线池,那么一定有很多线程,我们就需要对这些线程管理起来,所以我们需要一个容器,用来管理这些线程,但是此时我们要管理线程,我们是不是还需要先对这些线程进行先描述在组织?
属性:
那么这些线程里面有一些什么属性呢?
线程的名,线程的 id,线程的回调函数,以及线程传入的参数,所以我们就可以先对线程进行描述一下,也就是对线程进行封装一下,由于我们可能传入的并不是一个参数,所以我们也可以对传入的参数也进行封装。
下面是对线程参数的封装,和线程的封装:
下面为了简单,创建线程传入的参数就只有一个原本想要传入的参数,还有一个就是线程的名字。
typedef void*(*func_t)(void*); // 函数指针 类型 参数为 void* 返回值为 void*
Log log;
struct threadDate
{
string _name;
void* _args;
};
class Thread
{
public:
Thread(int thread_name, func_t routine, void* args)
:_name(string("thread ") + to_string(thread_name))
,_args(args)
,_routine(routine)
{}
private:
std::string _name; // 线程名
pthread_t _tid; // 线程 id
void* _args;
func_t _routine; // 线程要执行的函数
};
方法:
那么对线程的封装还需要什么呢?线程还需要什么方法呢?
仅仅有这个线程的参数和回调时没什么用的,所以需要让线程启动起来,也就是创建线程,所以i还需要一个创建线程的函数,start,那么只有启动线程的函数就够了吗?当然还需要一个可以 join 的函数,对该线程进行等待。
// 创建线程
void start()
{
threadDate* date = new threadDate{_name, _args};
pthread_create(&_tid, nullptr, _routine, (void*)date);
log(INFO, "%s 线程创建成功, 线程id: %ld", _name.c_str(), _tid);
}
// 线程等待
void join()
{
pthread_join(_tid, nullptr);
log(INFO, "%s 线程 join 成功", _name.c_str());
}
这里我们就让创建线程的时候,将一个 threaddate 的参数传入,里面包含了原本想要传入的参数,还有就是线程的名字(其实也可以不带,但是这里为了演示传入其他参数的方法)。
那么上面就是线程的封装,线程这些封装也就足够使用了。
属性:
那么线程池我们也是需要封装的,那么线程池我们需要一些什么属性呢?
线程池里面不光有一个线程,而线程我们以及描述好了,我们只需要在线程池中进行组织即可,那么我们使用什么来组织呢?我们可以使用一个 vector 来组织。
那么对于线程池来说,还需要什么属性呢?既然是一个线程池,那么没有线程的个数怎么可以?所以还需要线程的个数用于初始化线程池中线程的创建个数。
那么光这就够了吗?当然不够,既然我们前面说了线程池实际上也就是让一批线程拿任务,还有线程向线程池里面放任务,那么线程池也就是生产者消费者模型,所以我们当然还需要一个缓冲区(一个交易出场所),所以还需要一个 queue来充当交易场所。
那么如果是多线程的情况下,这个交易场所一定是临界资源,那么我们也就是一定需要加锁,所以我们需要一个锁,光有锁只能保证临界资源的安全,但是无法保证合理性,所以我们还需要一个条件变量。
template
class threadPool
{
public:
private:
int _tnum; // 线程的个数
std::queue _task_q; // 任务队列
std::vector _threads; // 管理线程的数据结构
pthread_mutex_t _mutex; // 互斥锁
pthread_cond_t _cond; // 条件变量
};
方法:
线程池的构造函数怎么写呢?既然是构造,那么首先就是要有一批线程的对象,所以线程池的构造函数,我们可以先new 一批线程的对象,然后当线程池启动的时候,我们就让这些线程开始执行任务的处理方法,我们可以在线程池的创建中,就需要将该线程需要处理的方法告诉线程,还有就是该线程的参数。
当有了这些参数后,就可以 New 线程的对象,然后因为这些都是线程所需要的参数,所以需要将这些参数都传入,然后将创建好的线程对象的 push 到用于管理线程的结构体中。
threadPool(int tnum = THREAD_NUM)
: _tnum(tnum)
{
// 初始化锁和条件变量
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
// 创建线程池
for (int i = 0; i < _tnum; ++i)
{
// 这里需要传入线程执行的回调,所以传入 routine (静态成员方法),为什么时静态的下面解释
_threads.push_back(new Thread(i + 1, routine, (void *)this));// 这里传入 这里传入 this指针?为什么一会解释
}
log(INFO, "thread pool创建成功~");
}
这里再创建 new 线程对象的时候,需要传入线程的名,这里使用的数字,再线程的构造函数中,他自己会根据这些数字,构建一个线程的名字(thread+数字)来表示线程的名字。
还有就是一把锁和一个条件变量的初始化,这个就不多解释了,不明白的可以看一下线程的互斥与同步这篇博客。
还有就是这里需要传入线程的回调函数,那么上面说明了,这里传入的是一个静态的成员函数,为什么传入静态的成员函数呢?
解释:线程的回调函数的类型是什么?void*(*)(void*) 也就是这个是一个返回值为 void* 参数为 void* 的一个类型,但是再C++中,C++的 struct 升级了,也和 class 一样,而类里面不仅可以有属性,还可以有方法,而类的普通的成员函数的方法是有一个隐藏的 this 指针的,也就是再函数的第一个参数,主要是为了表示是那个对象调用的该函数,所以这里如果传入的是成员函数,那么里面就多了一个隐藏的 this 的指针,所以如果将成员函数传入的话,虽然类型看起来是 void*(*)void* 但是实际上的类型是void*(*)void(type*, void*) 实际类型是还多了一个 type 类型的指针,所以传入的话与线程所需要的参数是不匹配的,所以这里就会编译不通过。
还有一个就是参数传入该线程池对象的 this 指针,这个后面解释。
既然有构造函数,而且构造函数里面我们还进行了 new 操作,所以一定需要再析构函数里面进行释放,那么一定还需要有析构函数:
~threadPool()
{
for (int i = 0; i < _tnum; ++i)
{
_threads[i]->join();
delete _threads[i];
}
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
log(INFO, "线程池销毁成功~");
}
析构函数就比较简单了,因为是线程,所以我们需要对这个线程进行 join 其实不进行 join 也可以,但是需要对线程进行 pthread_detach ,否则线程可能会类似僵尸进程一样的结构(导致系统资源泄露),而这就是为什么线程的类中需要一个 join 的函数。
还有就是既然锁和条件变量以及初始化过了,所以我们也一定不要忘记对这两个变量的销毁。
而线程池的析构函数会再该对象生命周期结束的时候自动调用该函数,所以再结束的时候会自动 join ,所以也就不用害怕内存泄露等问题了。
那么构造好后,说明现在已经有一个线程池的对象了,但是这歌线程池还没有启动,那么我们可以写一个函数可以让里面的线程都启动,也就是创建该线程,所以我们还有一个 run函数,就是为了让线程池启动,而别忘了每个线程对象中还有一个start 函数,这歌函数就是为了让线程创建:
void run()
{
for (int i = 0; i < _tnum; ++i)
{
_threads[i]->start();
}
log(INFO, "线程池启动成功~");
}
所以那么如何启动?因为再构造的时候,已将将每一个线程的对象需要执行的方法,以及方法的参数都传进入了,所以此时只需要create 即可,start函数就是直接 create ,可以看一下前面的代码。
那么线程池里面的线程是需要拿 _task_q(成员变量——队列)中的数据消费的,如果队列中没有数据的话,那么也就是没法消费的,所以我们还需要一个函数用来对该该队列进行插入数据:
void push_task(const T &task)
{
lockGuard lock(&_mutex);// 锁守卫,这里构造会自定加锁
_task_q.push(task);
weakup_cond();
log(DEBUG, "任务 push 成功~");
}// 当出作用域的时候, lock 对象会调用析构函数
将数据放入队列中的话需要放入到 _task_q 中,而线程池中的线程去数据也是从这个结构中取数据,所以该资源是临界资源,所以是需要加锁的,当加锁后就可以访问该资源。
这里的加锁我们可以看到,使用的是锁守卫,其实这个前面使用过一次,还是再看一下什么是锁守卫,其实锁守卫采用了RAII的思想,这里可以写一个类,该类的构造方法需要一把锁的地址,然后再构造的时候会自动加锁,当该类析构的时候会自动解锁,所以就不会忘记解锁了。
下面看一下锁守卫的代码,其实很简单:
// 锁守卫
struct lockGuard
{
lockGuard(pthread_mutex_t *mutex)
: _mutex(mutex)
{
pthread_mutex_lock(_mutex);
}
~lockGuard()
{
pthread_mutex_unlock(_mutex);
}
private:
pthread_mutex_t *_mutex;
};
这里将数据插入之后,那么还需要做什么?我们不要忘记了,如果没有数据的话,那么线程池的线程是再等待的,也就是需要再空数据的条件变量下等待,那么如果有了数据之后该怎么办?是不是应该唤醒一个线程,所以这里还应该调用唤醒的函数 signal/broadcast 所以,这里唤后,此时就会有一个/一批线程就取消费数据。
最后一个函数,也就是回调函数,那么回调函数就是这一批线程需要执行的方法了,那么这批线程需要干什么呢?既然是生产者消费者模型,那么这批线程是充当消费者的角色的,而主线程是充当生产者,而消费者就需要做的是将队列中的数据取出来,然后取处理这一批数据,那么取数据是不是访问临界资源?是的!所以就需要加锁,但是再取数据的第一步是什么?是不是查看是否还有资源?也就是检测临界资源,那么检测临界资源其实也是访问临界资源,既然是访问临界资源,那么就需要加锁,所以第一步就是加锁,然后检查是否还有临界资源,如果没有的话,那么就需要再条件变量下等待被唤醒,这就是为什么再主线程将数据添加到队列后就需要唤醒一个线程。
static void *routine(void *args)
{
threadDate *date = reinterpret_cast(args);
threadPool *self = reinterpret_cast(date->_args);
while (true)
{
T taskdate;
{
lockGuard lock(&(self->_mutex));
while (self->is_empty())
self->wait_cond();
log(DEBUG, "竞争锁成功,可以消费数据~");
taskdate = (self->_task_q).front();
(self->_task_q).pop();
log(DEBUG, "获取到任务~");
}
// 处理任务
log(INFO, "%s 处理任务完成 --- %d%c%d=%d", date->_name.c_str(), taskdate.get_x(), taskdate.get_op(), taskdate.get_y(), taskdate());
usleep(1000);
}
// 删除 args 这里的 args 是 new 出来的
delete date;
}
前面以及将这个函数的大概执行逻辑说明白了,前面我们不是说这个函数是一个 static 方法吗,既然是 static 返回发,那么可不可以访问像临界资源?还有锁这种成员变量呢?不可以,那么我们刚才说线程需要执行这个返回发,而且还需要加锁,加锁后又需要检验临界资源,如果不满足就需要条件变量,满足的话就需要取数据然后处理,那么前面说的这些都是成员变量,而 static 的函数,只能访问 static 的变量,还有函数,因为 static 的函数里面没有隐藏的 this 指针,所以无法访问到对象里面的数据,那么要怎么办呢?这就是为什么再线程的参数哪里传入 this 指针,如果有了这个指针,那么是不是可以访问到该类中的成员变量,还有其他的数据。
线程池就这些代码,而线程池也就写完了,那么既然有了线程池,我们就可以使用他,我们可以再主函数里面让主函数扮演生产者,将数据放入到这队列中,然后这些线程会自动处理这些数据,但是这里的任务需要提供一个仿函数,然后任务的对象调用仿函数就会自动处理任务。
const char* oper = "+-*/%";
int main()
{
threadPool tpool;
tpool.run();
srand(time(nullptr) ^ getpid());
// 获取任务
while(true)
{
int x = rand() % 100 + 1;
int y = rand() % 100 + 1;
char op = oper[rand() % 5];
task taskdate(x, y, op);
tpool.push_task(taskdate);
sleep(1);
}
return 0;
}
这里的任务使用的是之前使用过的一个任务,就是加减乘除模。