目录
本文主要叙述线程的创建/销毁/取消/回收等,线程的同步,线程的同步和数据的安全存储,等一些高级属性,最后还会解析一些现场开发常用的场景(如线程池的应用等)。
一 概述
线程可一看作一种“轻量级的进程",同时系统开销又小于使用进程;在同一进程内,可以创建多个线程,这些线程可以独立并发的执行自己的任务,并且可以共享该进程内的所有公共资源,以及共享同一份全局内存存储,其中包括初始化数据段,未初始化数据段,以及堆内存。
同时多线程应用编程时,也有缺点。编程时需要确保线程安全,如同一资源可能存在竞争;个个线程出现致命问题时,会影响整个进程的运行;每个线程对宿主进程的资源都是竞争使用等;
Linux多线程开发需要包含头文件<pthread.h>,在编译可执行程序时,要加上 "-lpthread" 链接线程库。
二 线程的创建和销毁/取消/回收
相关api如下,
//创建线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
//退出线程
void pthread_exit(void *retval);
//回收线程
int pthread_join(pthread_t thread, void **retval);
//分离线程
int pthread_detach(pthread_t thread);
//取消线程
int pthread_cancel(pthread_t thread);
//获取本线程的ID
pthread_t pthread_self(void);
//判断两个线程id是否相等
int pthread_equal(pthread_t t1, pthread_t t2);
1.线程的创建
线程创建时,可将attr设为NULL,代表使用默认属性,同时也可以根据需要,设置对应的属性,对应属性的设置,下文涉及到时会加以说明。
线程函数在使用时注意其函数返回值,若不关心,则可返回NULL,线程的返回值可以通过pthread_join来接收。
arg为创建线程时,要传给线程的参数,会做为线程功能函数的参数传给线程,此处需要注意的时,该参数的类型,若为局部变量,则需要注意,有可能在线程函数使用改参数时,该局部变量的作用域以销毁该变量。可在创建线程函数后加延时,并在线程函数启动后及时将该变量的值复制后使用。
2.线程的退出和回收
执行pthread_exit函数和线程函数执行return作用相同。
当线程未分离时,应当在线程结束后回收线程资源,此函数阻塞,等待对应线程结束为止
3.线程的分离
其主要作用为告诉系统,该线程不需要回收,结束后可直接销毁,可以避免等待线程结束这一步骤
4.线程的取消
线程取消执行时,正在运行的线程会在取消点推出线程,不必运行到return或者pthread_exit,具体相关操作,本文后续会详细叙述
5.线程ID
线程ID可以用来标识一个线程,大部分线程操作,也是根据线程ID来确定执行目标。在linux系统中,线程ID在所有进程中都是唯一的,但不能据此来识别不同进程中的线程,此种做法会降低程序的一致性。在线程回收或分离后的线程结束后,线程ID会被回收复用。
三 线程的同步与互斥
多线程编程时,由于同一资源的竞争使用,需要考虑线程间的同步与互斥,相关API如下
//互斥锁操作
//互斥锁静态初始化
pthrea_mutex_t mtx=PTHREAD_MUTEX_INITIALIZER;
//互斥锁动态初始化
int pthread_mutex_init(pthread_mutex_t *restrict_mutex,const pthread_mutextattr_t *restrict attr);
//互斥锁销毁
int pthread_mutex_destroy(pthread_mutex_t *restrict_mutext);
//加锁
int pthread_mutex_lock(pthread_mutex_t *restrict_mutext);
//解锁
int pthread_mutex_ublock(pthread_mutex_t *restrict_mutext);
//设置互斥锁属性
int pthread_mutexattr_init(pthread_mutexattr_t *mattr);
//销毁互斥锁属性对象
int pthread_mutexattr_destroy(pthread_mutexattr_t *mattr);
//检查互斥锁属性
int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr ,int *restrict type );
//设置互斥锁属性
int pthread_mutexattr_settype(pthread_mutexattr_t *attr , int type );
//条件变量操作
//动态初始化
int pthread_cond_init(pthread_cond_t *cv, const pthread_condattr_t *cattr);
//静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
//条件变量发布信号,只能唤醒一个等待线程
int pthread_cond_signal(pthread_cond_t *cond);
//条件变量广播信号,能唤醒所有等待线程
int pthread_cond_broadcast(pthread_cond_t *cond);
//条件变量等待信号
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
//条件变量超市等待信号
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime);
//销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
1.互斥锁操作
1.互斥锁的使用
互斥锁静态初始化和动态初始化的作用相同,区别如下,动态初始化可在任何需要锁的地方初始化,静态初始化需要保证在所有可能使用到锁的地方的最前面初始化,此外,动态初始化还可以设置锁的属性
另一个要注意的就是死锁问题,两个线程均持有对方的锁,并等待自己的锁,而陷入死循环中,死锁问题在调试时,可增加加锁解锁的打印,或者设置锁的属性,便于查找问题
2.互斥锁的属性
使用pthread_mutex_settype来设置属性,然后再创建互斥锁时,将属性对象作为参数传递给互斥锁对象。
列举常见的三个属性
-
PTHREAD_MUTEX_NORMAL 线程的默认属性,
-
PTHREAD_MUTEX_ERRORCHECK 检查线程的操作,包裹死锁,解锁未加锁的互斥量等等,运行起来比一般互斥量要慢
-
PTHREAD_MUTEX_RECURSIVE 递归互斥量维护一个锁计数器,只有当加锁次数和解锁次数啊相同时,锁才会释放
2. 条件变量操作
1.条件变量得使用
条件变量需要配合互斥锁使用,在使用pthread_cond_wait前,需要对使用到的互斥锁进行加锁,然后循环等待条件变量信号,在条件变量等待函数使用完毕后,需要对使用到的互斥锁进行解锁;pthread_cond_timedwait也是如此
四 线程安全
涉及到的相关API如下:
//一次性初始化函数
int pthread_once(pthread_once_t *once_control, void(*init)(void));
//创建线程特有数据的key及缓冲区
int pthread_key_create(pthread_key_t *key, void(*destructor)(void*));
int pthread_setspecific(pthread_key_t *key, const void *value);
void *pthread_getspecific(pthread_key_t *key);
线程的两个重要特征:1.并发 2.共享同一个进程中的资源。导致在线程使用时不得不考虑线程安全问题,主要有以下几个方面:
1.可重入函数
从安全层面强,线程所调用的所有函数必须是可重入的。原因是,多个线程可能同时调用同一个函数,而在该函数内,可能该表某些进程下的公共资源(如全局变量等),从而影响到其他线程。可从两个方面解决该问题:
1.确保函数未串行调用,即利用锁等手段,在同一时刻只允许有一个线程调用
2.确保函数可重入,即函数内不涉及公共资源,从而保证线程调用是没有互相影响
此外,调用Linux API时,也要保证调用的函数时线程安全的。
2.一次性初始化
某些线程函数可能被反复使用,销毁。但其中有一部分初始化动作却只能调用一次;
例如,当创建一个线程定期发送tcp数据包到服务器时,创建套接字只能在第一次完成,此后线程销毁,再次条用线程发送数据包时,创建套接字的部分无需再次调用
此时可将该线程初始化部分提炼成一个函数,并在该线程中调用pthread_once,将该函数作为pthread_once的参数,即可实现要求
3. 线程特有数据
下程序对于传入参数均为做安全检查,仅做说明问题使用!
//线程特有数据示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
char *string[5] = {"this is first string!",
"this is second string!",
"this is thrid string!",
"this is forth string!",
"this is fifth string!"};
pthread_once_t once = PTHREAD_ONCE_INIT;
pthread_key_t pthreadKey;
static void destructor(void *buf)
{
free(buf);
}
static void createKey(void)
{
int res;
res = pthread_key_create(&pthreadKey, destructor);
if (res != 0) printf("create key error!\n");
}
char *putoutstring(char *value)
{
char *result = NULL;
int res;
res = pthread_once(&once, createKey);
if (res != 0) printf("init once error!\n");
result = pthread_getspecific(pthreadKey);
if (result == NULL) {
result = (char *)malloc(512);
pthread_setspecific(pthreadKey, result);
}
strcat(result, value);
return result;
}
void *threadStringOne(void *argv)
{
printf("thread one1:%s\n",putoutstring(string[0]));
printf("thread one2:%s\n",putoutstring(string[1]));
return NULL;
}
void *threadStringTwo(void *argv)
{
printf("thread two1:%s\n",putoutstring(string[1]));
printf("thread two2:%s\n",putoutstring(string[2]));
return NULL;
}
int main(int argc, char* argv[])
{
pthread_t tid1,tid2;
pthread_create(&tid1, NULL, threadStringOne, NULL);
pthread_create(&tid1, NULL, threadStringTwo, NULL);
pthread_join(tid1, NULL);
pthread_join(tid1, NULL);
return 0;
}
运行输出
在该示例程序中,两个线程的功能相同,都是调用putoutstring(该函数记将每次传入的字符串与之前传入的字符串用strcat连接起来,并返回给调用者),但是传入的参数不同。两个线程第二次调用putoutstring时,返回的字符串均只包含本线程传入该函数的字符串,而不是包含所有调用putoutstring时传入的字符串。
原因为putoutstring使用线程特有数据,将函数内部用于记录历史传入字符串的变量与调用线程关联起来的,线程传入的字符串存储在了与本线程相关的存储区域,互不影响。这种做法保证了不同线程调用同一个函数,运行过程中相关数据只在本线程内起作用,而不会影响其他调用者,从而保证了线程安全。
此处由于存储在线程特有数据中的为存储区域的指针,故destructor中为释放存储区域的操作,存储在线程特有数据中的数据也可以不是一个指向存储区域的指针,此时pthread_key_create的第二个参数应该设为NULL;
若此处不适用线程特有数据会怎样?
此处若不使用线程特有数据,那么当线程第二次调用putoutstring时返回回来的数据包含另一个线程调用传入的字符串,且可能字符串顺序可能会与预期不同,字符串内部可能会出现两个字符串交叉打印,此处由于是两个线程先后创建,故对函数putoutstring的调用也不是同时进行,因此取消使用线程特有数据后,错误结果可能不明显。如果想测试错误结果,可使用信号量,在两个线程中等待信号量,同时进行函数调用,结果会更明显。
4. 线程局部存储
类似线程特有数据,可以使用线程局部存储来达到何线程特有数据相类似的。当全局变量使用 __thread 修饰时,被修饰的变量在各个线程中都有自己的一份副本拷贝,且不相互影响如 :
static __thread char buf[512];
仍需要注意以下几点:
-
如果变量生命中使用了关键字static或extern ,那么关键字__thread应该紧随其后
-
与常规全局或静态变量相同,该变量可以在声明时设置一个初始值
-
可以使用C语言取址操作符&,来获取该变量的地址
五 线程取消点与清理函数
相关api如下
//线程取消
int pthread_cancel(pthread_t thread);
//设置取消状态
int pthread_setcanceltate(int type, int *oldstate);
//设置取消类型
int pthread_setcanceltype(int type, int *oldtype);
//线程可取消性的检测
void pthread_testcancel(void);
//线程清理函数
void pthread_cleanup_push(void (*routine)(void*), void *arg);
void pthread_cleanup_pop(int execute);
1.取消状态及类型
调用 pthread_setcancelstate来设置取消状态,取消状态有PTHREAD_CANCEL_DISABLE和PTHREAD_CANCEL_ENABLE两种,分别对应线程可以取消或者不可以取消,线程默认可以取消,同时在线程中可以通过设置线程取消状态来保护一段不可中断的代码。当开始执行该代码段时,设置线程为不可取消,执行完毕再次设置线程为可取消状态。当线程取消状态为enable时,线程具体的取消规则按照取消类型来确定
调用pthread_setcanceltype来设置取消类型,取消类型有PTHREAD_CANCEL_DEFERRED和PTHREAD_CANCEL_ASYNCHRONOUS;PTHREAD_CANCEL_ASYNCHRONOUS为一部徐晓,可能在任何一个时间点(也许是立即取消,但不一定)取消线程。异步取消的场景很少,后续讨论。PTHREAD_CANCEL_DEFERRED为取消请求保持挂起状态,直至到达取消点。
2.取消点
当线程状态设置为PTHREAD_CANCEL_ENABLE和PTHREAD_CANCEL_DEFERRED时,取消请求和取消点才会起作用。大部分为阻塞函数,部分必须是取消点的函数,函数如下:
除此之外,还有stdio函数,dlopenAPI,syslogAPI,nftw( ), popen( ), semop( ), unlink( )等,具体使用时,还需要进一步查询。
此外,若是线程无取消点应该如何处理?可使用pthread_testcancle函数,在线程中调用该函数,当调用该函数时,可在此函数出检测线程是否取消,即该函数不做任何操作,但在其调用处,为线程取消点。
示例代码如下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
void *treadCancel(void *argv)
{
pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, NULL);
printf("step1\n");
sleep(1);
printf("step2\n");
sleep(1);
printf("step3\n");
sleep(1);
printf("step4\n");
sleep(1);
printf("step5\n");
sleep(1);
return NULL;
}
int main(int argc, char* argv[])
{
pthread_t tid;
pthread_create(&tid, NULL, treadCancel, NULL);
sleep(2);
pthread_cancel(tid);
pthread_join(tid, NULL);
return 0;
}
程序输出:
此处创建线程后每隔一秒打印一行字符,主进程在创建完线程后sleep两秒后取消线程,此时线程在18行出执行sleep,为取消点,故此时立即推出。
3.清理函数
pthread_cleanup_push()和pthread_cleanup_pop()成对出现,此处强调,必须成对出现。在linux内,这两个函数被实现为宏,可展开为由{和}所包裹的语句序列,因此必须成对出现。
另一方面,线程并不是可以随时推出,若到达取消点时,线程内存在已经通过malloc分配的内存,则此时退出会造成内存泄露。
清理函数示例如下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
char *ptr = NULL;
void cleanupfun(void *arg)
{
if (arg != NULL) {
printf("free ptr!\n");
free(arg);
} else {
printf("don't need free!\n");
}
}
void *treadCancel(void *argv)
{
pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, NULL);
ptr = (char *)malloc(512);
pthread_cleanup_push(cleanupfun, ptr);
strcpy(ptr, "this is step1!");
printf("step1:%s\n",ptr);
sleep(1);
strcpy(ptr, "this is step2!");
printf("step2:%s\n",ptr);
sleep(1);
strcpy(ptr, "this is step3!");
printf("step3:%s\n",ptr);
sleep(1);
strcpy(ptr, "this is step4!");
printf("step4:%s\n",ptr);
sleep(1);
strcpy(ptr, "this is step5!");
printf("step5:%s\n",ptr);
sleep(1);
pthread_cleanup_pop(1);
return NULL;
}
int main(int argc, char* argv[])
{
pthread_t tid;
pthread_create(&tid, NULL, treadCancel, NULL);
sleep(2);
pthread_cancel(tid);
pthread_join(tid, NULL);
printf("result:%s\n", ptr);
return 0;
}
输出结果如下
运行过程和上一小节的取消点示例相似,却别为此处打印了线程内部分配空间中的内存,已注册的清理函数在线程推出时会调用清理函数来执行清理动作,注意在清理时,注意判断资源是否释放,此处由于对应空间中的资源已经释放,故在线程销毁后,main函数中对字符串中的打印无效。
六 线程池操作
线程池,顾名思义,其提前创建若干线程,来处理一系列的任务。与临时创建线程,来处理某个任务的方式相比,当任务数量较多,且单个任务执行时间较短时。会造成linux分配过多的资源用于创建/销毁线程,而间接降低了任务的执行速度,对新任务的反应速度。
对于线程池的实现,关键点如下:
1.线程池的管理
2.任务队列,任务结构体的设计
3.任务分配方式
下面对线程池的关键点进行叙述
1.线程池的工作流程
1.线程池初始化
初始化一定数量的线程池,初始化任务队列,同时初始化一个线程,用于管理线程池,进行任务调度
2.等待任务
线程池创建完毕后,阻塞等待任务队列中的任务来
3.添加任务
编写线程池时,同时需要编写相关的接口,用于向任务队列中添加任务。任务添加后通知管理线程。
4.任务分分配及执行
线程池管理线程通过之前拟定的分配规则,讲任务发送到对应的线程执行,通常情况是,发布一个信号,抢占到信号的线程执行线程
5.资源回收
2.不定容线程池----线程池线程数量的讨论
常规情况下,线程池的数量在一开始就是确定好的,但是这种方法不适用于任务量变化较大的场景。若线程池过多,则造成资源浪费,线程池过少,则造成任务的阻塞;
此时可根据当前任务队列中的等待任务数和当前工作线程数量来确定当前线程池中线程总数。据此对当前线程池中的已有线程进行销毁,或者创建新的线程。同时应该注意,必须限定线程池最小和最大线程数量,防止意外情况。
3. 任务分配方式
通常情况下使用抢占式的任务分配方式。即每当任务队列中加入一个任务后,发布一个信号量,所有处于等待状态的线程会去接收这个信号量,但只有一个线程能够接收到,此时对队列加锁,取出任务后执行。
若任务队列中的任务优先级不同如何处理?
可以使用优先级队列,即保证优先级高的任务总是被先执行。但是存在一个极端情况,若当前线程池中的线程全部在执行低优先级任务,此时队列中新增一个高优先级任务,此时高优先级任务只能处于等待状态。另一种方法时,总为高优先级任务预留一定数量的线程,不得用于低优先级任务,可以解决此种情况