Linux下c语言的线程池(分模块讲解)
一:线程池的概念、构成、作用。
1、概念:
维持管理固定数量的池式结构。与此结构类似的还有:内存池、对象池、数据库连接池等。他们的共同点是复用资源。
2、构成:
其中有我们比较熟悉的生产消费者模型,然而关于生产消费者模型的具体应用其中就有咱们的线程池。线程池包括生产者线程、任务队列、消费者线程我们根据这三个组成部分可以分模块实现不同的功能。
3、作用:
1:性能优化:不过度占用核心线程,异步执行耗时任务。
2:并发执行:充分利用多核,并发执行核心业务。
二:关于线程池结构的设计。
由于这次使用的是C语言,没有像C++的一些STL容器可以直接使用。因此咱们通过链表来进行实现。对于这三个组成部分来说,这两个线程都需要其中的任务队列,所以我们选择从任务队列着手设计,但是队列中又包含一个一个的结点,所以最开始设计的是其中最底层的结点。
1:我们开始思考这个任务结点怎么设计,在这个链表队列中,肯定不能缺少指向下一个元素的指针(next指针),然后这个是一个任务节点,因此这个结点还要包含我们要执行的任务(函数指针,回调函数),最后需要一些这个函数所需要的参数(arg);
//首先要在全局声明这个回调函数的名称
typedef void (*handler_pt)(void * /* ctx */);
//通过链表来实现,这里是一个一个的任务结点
typedef struct task_s {
void *next; //指向下一个结点
handler_pt func; //任务的回调函数
void *arg; //所需要的参数
} task_t;
2:设置好结点之后,开始思考队列怎么实现?都有结点了,肯定不能少了头指针和尾指针。然后还要有个变量代表阻塞和非阻塞,这个是为了当修改这个值的时候,让所有的线程退出的操作。然后就是关于互斥锁和条件变量了,这两个总是配对出现。当有了互斥锁了还需要自旋锁吗?其实不需要自旋锁了,但是为了了解自旋锁的使用方法,所以这个队列加上了自旋锁。
//任务队列
typedef struct task_queue_s {
void *head; //指向头节点
void **tail; //指向最后一个元素的next(使用二级指针可以减少代码量)
int block; //用来表示是不是阻塞的,默认是阻塞的,当要关闭的时候要改变为非阻塞
spinlock_t lock; //自旋锁:关于队列的操作可以单独加锁(不需要这个自旋锁也是可以的)
pthread_mutex_t mutex; //互斥锁(对于不使用自旋锁的话可以复用互斥锁)
pthread_cond_t cond; //条件变量:要搭配互斥锁共同工作,用来唤醒线程
} task_queue_t;
上面的二级指针可以通过下面这个图来理解。简单来说就是这个二级尾指针就是指向的是最后一个元素的next。

3:最后设计线程池。这个线程池要使用哪个队列呢?所以要包含这个队列的指针。还要有个标志位,代表着线程的状态,当这个标志位修改的时候,代表全部线程即将退出。既然是个池,肯定不会只有一个线程,所以要包含线程的数量,以及线程指针。
//线程池的结构
struct thrdpool_s {
task_queue_t *task_queue; //咱们的阻塞队列
atomic_int quit; //原子变量:原子操作,不用单独加锁了。线程安全
int thrd_count; //线程池中线程的数量
pthread_t *threads; //线程池中的线程,需要开辟一块内存出来。
};
上述三个结构都已经创建好了,那么接下来就是实现各种接口了。那么咱们可以想一下都需要什么接口呢?首先就是队列的创建吧,既然有创建的话,根据对称式设计,那么也就需要一个队列的销毁。当然在队列中还有个阻塞非阻塞的变量,咱们要通过这个变量来判断是否要退出线程是否要退出,因此要通过这个来进行广播退出的消息。对于这个队列来说,肯定有添加和删除的接口吧,然后咱们再给消费者提供一个直接拿去任务的接口。这样关于队列的一些接口就了解了。
对于线程池的接口,肯定有线程池的创建和销毁,还有一个供生产者进行生产的函数,以及线程创建好之后,这个线程从哪个函数启动,这个函数需要我们根据业务自己去实现。全部的接口都已经说清楚。
三:各个接口的实现。
对于每个模块的设计,都要遵循回滚式编程,一开始我们假设操作是成功的,然后给出失败的代码
1:队列的创建。__taskqueue_create
static task_queue_t*
__taskqueue_create() {
int ret;
task_queue_t* queue = (task_queue_t*)malloc(sizeof(task_queue_t));//开辟空间
if (queue) {
ret = pthread_mutex_init(&queue->mutex, NULL); //初始化互斥锁和条件变量
if (ret == 0) {
ret = pthread_cond_init(&queue->cond, NULL);
if (ret == 0) { //初始化其他变量
spinlock_init(&queue->lock);
queue->head = NULL;
queue->tail = &queue->head;
queue->block = 1; //是否为阻塞状态
return queue;
}
pthread_mutex_destroy(&queue->mutex);
}
free(queue);
}
return NULL;
}
2:队列的销毁。__taskqueue_destroy
//队列的销毁
static void
__taskqueue_destroy(task_queue_t* queue) {
task_t* task;
while ((task = __pop_task(queue))) { //将队列中全部的任务取出来并进行释放
free(task);
}
spinlock_destroy(&queue->lock); //将队列中别的资源也进行销毁
pthread_cond_destroy(&queue->cond);
pthread_mutex_destroy(&queue->mutex);
free(queue);
}
3:队列添加的操作。__add_task
static inline void
__add_task(task_queue_t* queue, void* task) {
// 不限定任务类型,只要该任务的结构起始内存是一个用于链接下一个节点的指针
void** link = (void**)task; //如果你不知道这个指向下一个结点的指针叫什么(next),那就用二级指针(link)
*link = NULL; //二级指针直接指向这个指向下一个节点的地址,也就是不需要这个指针的名称了
spinlock_lock(&queue->lock); //给队列加锁,避免多个任务同时进入队列
*queue->tail /* 等价于 queue->tail->next */ = link; //这里是让最后一个的指针next指向新进来的next
queue->tail = link; //然后这个是修改尾指针,指向新进来的next的指针(跟链表的插入相似)
spinlock_unlock(&queue->lock);
pthread_cond_signal(&queue->cond); //唤醒线程,告诉线程我现在有任务了
}
4:队列的删除操作。__pop_task
//这个模块没啥好说的,就单纯的取出头节点,然后删除
static inline void*
__pop_task(task_queue_t* queue) {
spinlock_lock(&queue->lock);
if (queue->head == NULL) {
spinlock_unlock(&queue->lock);
return NULL;
}
task_t* task;
task = queue->head;
void** link = (void**)task;
queue->head = *link;
if (queue->head == NULL) {
queue->tail = &queue->head;
}
spinlock_unlock(&queue->lock);
return task;
}
5:消费者使用的get_task函数
如果单纯使用上面的队列删除操作来获取任务的话,,是不安全的,因为有多个线程都在取任务,因此为了线程安全,咱们还要封装一个消费者可以安全的取出任务的操作。
//消费者使用的接口
static inline void*
__get_task(task_queue_t* queue) {
task_t* task;
// 虚假唤醒,这里用while循环是为了防止虚假唤醒,当被唤醒之后,要再次检查
//为什么有虚假唤醒:可能是内部的信号导致
while ((task = __pop_task(queue)) == NULL) {
pthread_mutex_lock(&queue->mutex);
if (queue->block == 0) { //这里是当线程是非阻塞的时候,代表我们要退出线程了,不在取任务了
pthread_mutex_unlock(&queue->mutex);
return NULL;
}
// 1. 先 unlock(&mtx)
// 2. 在 cond 休眠
// --- __add_task 时唤醒
// 3. 在 cond 唤醒
// 4. 加上 lock(&mtx); //一开始,队列都为空,所以会阻塞在这里,当有任务添加
pthread_cond_wait(&queue->cond, &queue->mutex); //这个get函数会被唤醒,因此会再走一遍这个程序,也就能取出这个任务
pthread_mutex_unlock(&queue->mutex);
//在pthread_cond_wait 内部是
//1:先pthread_mutex_unlock 解锁,然后当前线程休眠.2:当被内个signal唤醒的时候,会pthread_mutex_lock 加锁
//有人会说这里while循环那里都没有锁,怎么能线程安全呢?其实当线程全部创建以后,线程都会去判断队列有没有任务
//刚创建的队列肯定没任务,因此都休眠在了pthread_cond_wait 这里,然后它内部最后是加锁,但是外面还有个解锁呢
}
return task; //这里是当上面返回的不为NULL时。
}
6:配合线程退出的队列操作。__nonblock
//将队列的状态修改为非阻塞状态,代表着线程和队列即将退出,不会再接收任务
static void
__nonblock(task_queue_t* queue) {
pthread_mutex_lock(&queue->mutex);
queue->block = 0; //修改为非阻塞
pthread_mutex_unlock(&queue->mutex);
pthread_cond_broadcast(&queue->cond); //广播,将全部线程全部唤醒
}
7:线程池的创建。threads_create
//复杂资源创建:对称式的接口设计
//回滚式设计
//下面是线程池的创建:创建哪个线程池,创建其中的几个线程
static int
__threads_create(thrdpool_t* pool, size_t thrd_count) {
pthread_attr_t attr; //线程的属性,使用之前要先初始化,用完后要去除初始化
int ret;
ret = pthread_attr_init(&attr); //初始化
if (ret == 0) {
pool->threads = (pthread_t*)malloc(sizeof(pthread_t) * thrd_count); //创建其中的线程
if (pool->threads) {
int i = 0;
for (; i < thrd_count; i++) {
if (pthread_create(&pool->threads[i], &attr, __thrdpool_worker, pool) != 0) {
break; //其中的__thrdpool_worker 这个函数,是我们用户态要去写的函数,线程创建好之后,要走这个函数
}
}
pool->thrd_count = i;
pthread_attr_destroy(&attr); //线程用完之后要去除初始化
if (i == thrd_count)
return 0;
//如果线程未全部创建,那就全部销毁掉
__threads_terminate(pool);
free(pool->threads);
}
ret = -1;
}
return ret;
}
thrdpool_t*
thrdpool_create(int thrd_count) {
thrdpool_t* pool;
pool = (thrdpool_t*)malloc(sizeof(*pool));
if (pool) {
task_queue_t* queue = __taskqueue_create(); //先创建任务队列,也就是说这个任务队列属于这个线程池
if (queue) {
pool->task_queue = queue;
atomic_init(&pool->quit, 0);
if (__threads_create(pool, thrd_count) == 0) //然后创建多个线程
return pool;
__taskqueue_destroy(queue);
}
free(pool);
}
return NULL;
}
8:线程池的销毁。thrdpool_terminate
//线程的销毁(这个是运行后,告诉线程要进行退出)
void
thrdpool_terminate(thrdpool_t* pool) {
atomic_store(&pool->quit, 1);
__nonblock(pool->task_queue); //修改任务队列的阻塞为非阻塞
}
9:生产者使用的任务添加。thrdpool_post
//这里传入消费者线程池,就可以将任务添加到任务队列里面了
int
thrdpool_post(thrdpool_t* pool, handler_pt func, void* arg) {
if (atomic_load(&pool->quit) == 1)
return -1;
task_t* task = (task_t*)malloc(sizeof(task_t));
if (!task) return -1;
task->func = func;
task->arg = arg;
__add_task(pool->task_queue, task);
return 0;
}
10:用户态函数。__thrdpool_worker
static void*
__thrdpool_worker(void* arg) {
thrdpool_t* pool = (thrdpool_t*)arg; //这里的arg是参数信息,在这里是将线程池的信息传递进来,在下面可以用到线程池的东西。
task_t* task;
void* ctx;
while (atomic_load(&pool->quit) == 0) {
task = (task_t*)__get_task(pool->task_queue); //取任务
if (!task) break;
handler_pt func = task->func; //函数指针
ctx = task->arg;
free(task);
func(ctx); //给函数传递参数,运行这个函数
}
return NULL;
}
欸?到这里我有个疑问,当线程全部退出的时候,进行广播然后线程将队列中剩余的任务全部取出来,到这个时候,我发现没有锁,管理这个任务,会发生线程不安全,后来发现在删除操作里面有自旋锁保护着呢,所以线程很安全。但还有个小疑问,上面写了好多关于消费者的函数,而关于生产者只有一个添加的操作,如果只看这些的话,我可能还是不会使用这个线程池,因此查看了例子。
constexpr int64_t n = 1000000;
void producer(thrdpool_t *pool) {
for(int64_t i=0; i < n; ++i) {
thrdpool_post(pool, JustTask, NULL);
}
}
void test_thrdpool(int nproducer, int nconsumer) {
auto pool = thrdpool_create(nconsumer); //创建线程池并返回线程池
for (int i=0; i<nproducer; ++i) { //创建生产者线程,并赋予任务函数
std::thread(&producer, pool).detach();
}
time_t t1 = GetTick();
// wait for all producer done
while (g_count.load() != n*nproducer) {
usleep(100000);
}
time_t t2 = GetTick();
std::cout << t2 << " " << t1 << " " << "used:" << t2-t1 << " exec per sec:"
<< (double)g_count.load()*1000 / (t2-t1) << std::endl;
thrdpool_terminate(pool);
thrdpool_waitdone(pool);
}
int main() {
// test_thrdpool(1, 8);
test_thrdpool(4, 4); //四个生产者,四个消费者
return 0;
}
到这里C语言的线程池已经讲解完毕,第一次写博客写的很垃圾,请大家见谅。https://xxetb.xetslk.com/s/2D96kH
更多推荐


所有评论(0)