我们已经了解进程的基本概念:进程是正在执行的程序,并且是系统资源分配的基本单位。当用户需要在一台计算机上去完成多个独立的工作任务时,可以使用多进程的方式,为每个独立的工作任务分配一个进程。多进程的管理则由操作系统负责——操作系统调度进程,合理地在多个进程之间分配资源,包括CPU资源、内存、文件等等。除此以外,即便是一个单独的应用,采用多进程的设计也可以提升其吞吐量,改善其响应时间。假如在处理任务的过程中,其中一个进程因为死循环或者等待IO之类的原因无法完成任务时,操作系统可以调度另一个进程来完成任务或者响应用户的请求。比如在文本处理程序当中,可以并发地处理用户输入和保存已完成文件的任务。
随着进程进入大规模使用,程序员在分析性能的时候发现计算机花费了大量的时间在切换不同的进程上面。当一个进程在执行过程中,CPU的寄存器当中需要保存一些必要的信息,比如堆栈、代码段等,这些状态称作上下文。上下文切换这里涉及到大量的寄存器和内存之间的保存和载入工作。除此以外,Linux操作系统还支持虚拟内存,所以每个用户进程都拥有自己独立的地址空间,这在实现上就要求每个进程有自己独立的页目录表。所以,具体到Linux操作系统,进程切换包括两步:首先是切换页目录表,以支持一个新的地址空间;然后是进入内核态,将硬件上下文,即CPU寄存器的内容以及内核态栈切换到新的进程。
为了缩小创建和切换进程的开销,线程的概念便诞生了。线程又被称为轻量级进程(Light Weight
Process, LWP),我们将一个进程分解成多个线程,每个线程是独立的运行中程序实体,线程之间并发运行,这样线程就取代进程成为CPU时间片的分配和调度的最小单位,在Linux操作系统当中,每个线程都拥有自己独立的 task_struct 结构体,当然,在属于同一个进程多个线程中, task_struct 中大量的字段是相同的或者是共享的。
注意到在Linux操作系统中,线程并没有脱离进程而存在。而计算机的内存、文件、IO设备等资源依然还是按进程为单位进行分配,属于同一个进程的多个线程会共享进程地址空间,每个线程在执行过程会在地址空间中有自己独立的栈,而堆、数据段、代码段、文件描述符和信号屏蔽字等资源则是共享的。
同一个进程的多个线程各自拥有自己的栈,这些栈虽然是独立的,但是都位于同一个地址空间中,
所以一个线程可以通过地址去访问另一个线程的栈区。
因为属于同一个进程的多个线程是共享地址空间的,所以线程切换的时候不需要切换页目录表,而且上下文的内容中需要切换的部分也很少,只需要切换栈和PC指针以及其他少量控制信息即可,数据段、代码段等信息可以保持不变。因此,切换线程的花费要小于切换进程的。
在Linux文件系统中,路径 /proc 挂载了一个伪文件系统,通过这个伪文件系统用户可以采用访问
普通文件的方式(使用read系统调用等),来访问操作系统内核的数据结构。其中,在 /proc/[num] 目录中,就包含了pid为num的进程的大量的内核数据。而在 /proc/[num]/task就包含本进程中所有线程的内核数据。我们之前的编写进程都可以看成是单线程进程。
用户级线程和内核级线程
根据内核对线程的感知情况,线程可以分为用户级线程和内核级线程。操作系统内核无法调度用户级线程,所以通常会存在一个管理线程(称为运行时),管理线程负责在一个用户线程陷入阻塞的切换到另一个线程。如果线程无法陷入阻塞,则用户需要在代码当中主动调用yield以主动让出,否则一个运行中的线程将永远地占用CPU。用户级线程在CPU密集型的任务中毫无作用,而且也无法充分多核架构的优势,所以几乎所有的操作系统都支持内核级线程。
由于用户级线程在处理IO密集型任务的时候有着一定的优势,所以目前在一些服务端框架中,混合性线程也引入使用了——在这种情况下,会把用户级线程称为有栈协程。应用程序会根据硬件中CPU的核心数创建若干个内核级线程,每一个内核级线程会对应多个有栈协程。这样在触发IO操作时,不再需要陷入内核态,直接在用户态切换线程即可。
目前使用最广泛的线程库名为NPTL (Native POSIX Threads Library),在Linux内核2.6版本之后,它取代了传统的LinuxThreads线程库。NPTL支持了POSIX的线程标准库,在早期,NPTL只支持用户级线程,随着内核版本的迭代,现在每一个用户级线程都对应一个内核态线程,这样NPTL线程本质就成为了内核级线程,可被操作系统调度。
线程的创建和终止
使用线程的思路其实和使用进程的思路类似,用户需要去关心线程的创建、退出和资源回收等行为,初学者可以参考之前学习进程的方式对比学习线程对应的库函数。下面是常用的线程库函数和之前的进程的对应关系。
线程 | 函数功能 | 类似的进程函数 |
pthread_create | 创建一个线程 | fork |
pthread_exit | 线程退出 | exit |
pthread_join | 等待线程结束并回收资源 | wait |
pthread_self | 获取线程id | getpid |
在使用线程相关的函数之后,在链接时需要加上 -lpthread 选项以显式链接线程库。
线程函数的错误处理
之前的POSIX系统调用和库函数在调用出错的时候,通常会把全局变量 errno 设置为一个特别的数值以指示报错的类型,这样就可以调用 perror 以显示符合人阅读需求的报错信息。但是在多线程编程之中,全局变量是各个线程的共享资源,很容易被并发地读写,所以pthread系列的函数不会通过修改 errno来指示报错的类型,它会根据不同的错误类型返回不同的返回值,使用 strerror 函数可以根据返回值显示报错字符串。
char *strerror(int errnum);
define THREAD_ERROR_CHECK(ret,msg) {if(ret!=0){fprintf(stderr,"%s:%s\n",msg,strerror(ret));}}
创建线程
pthread_create
线程创建使用的函数是 pthread_create ,这个函数的函数原型如下:
#include <pthread.h>int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine)(void *), void *arg);
参数说明
pthread_t *thread
用于存储新创建线程的线程 ID。线程 ID 是线程的唯一标识符,类型为pthread_t
。在不同的操作系统中,pthread_t底层实现不同,具体到Linux是一个无符号整型数,使用函数 pthread_self 可以获取本线程的id。const pthread_attr_t *attr
指定线程的属性。如果不需要设置特殊属性,可以传入NULL
,使用默认属性。void *(*start_routine)(void *)
指定线程的入口函数。该函数必须接受一个void *
类型的参数,并返回一个void *
类型的值。void *arg
传递给线程入口函数的参数。如果不需要传递参数,可以传入NULL
。
返回值
成功时返回
0
。失败时返回错误码(非零值),例如
EAGAIN
(资源不足)、EINVAL
(无效参数)等。
示例:
void * threadFunc(void *arg){printf("I am child thread, tid = %lu\n", pthread_self());return NULL;
}
int main(){pthread_t tid;int ret = pthread_create(&tid,NULL,threadFunc,NULL);THREAD_ERROR_CHECK(ret,"pthread_create");printf("I am main thread, tid = %lu\n", pthread_self());sleep(1);return 0;
}
如果没有设置sleep(1)导致主线程提前退出,我们可能会看到这样一个现象,标准输出上有一定的几率显示两条相同的语句。产生这种问题的原因是 stdout 缓冲区在多个线程之间是共享,当执行 printf 时,会首先将stdout的内容拷贝到内核态的文件对象中,再清空缓冲区,当主线程终止导致所有线程终止时,可能子线程已经将数据拷贝到了内核态(此时第一份语句已经打印了),但是stdout的内容并未清空,此时进程终止,会把所有缓冲区清空,清空缓冲区的行为会将留存在缓冲区的内容直接清空并输出到标准输出中,此时就会出现内容的重复打印了。
线程和数据共享
共享全局变量
int global = 2;
void *threadFunc(){printf("I am child thread, tid = %lu\n", pthread_self());printf("%d\n", global);
}int main(int argc, char const *argv[]){pthread_t tid;int ret = pthread_create(&tid, NULL, threadFunc, NULL);THREAD_ERROR_CHECK(ret,"pthread_create");printf("I am main thread, tid = %lu\n", pthread_self());sleep(1);return 0;
}
共享堆
void *threadFunc(void *arg){printf("I am child thread, tid = %lu\n", pthread_self());char *p = (void *)arg;printf("%c\n", *p);
}int main(int argc, char const *argv[])
{char *p = (char*)malloc(sizeof(char));*p = 'C';pthread_t tid;int ret = pthread_create(&tid, NULL, threadFunc, (void *)p);THREAD_ERROR_CHECK(ret,"pthread_create");printf("I am main thread, tid = %lu\n", pthread_self());sleep(1);return 0;
}
虽然说 arg 是一个 void* 类型的参数,这暗示着用户可以使用该参数来传递一个数据的地址,但是有些情况下我们只需要传递一个整型数据,在这种情况,用户可以直接把 void* 类型的参数当成是一个8字节的普通数据(比如long)进行传递。
访问另一个线程的栈
虽然各个线程执行的过程中拥有自己独立的栈区,但是这些所有的栈区都是在同一个地址空间当中,所以一个线程完全可以访问到另一个线程栈帧内部的数据。
void *threadFunc(void *arg){printf("I am child thread, tid = %lu\n", pthread_self());int *p = (int *)arg;printf("%d\n", *p);
}int main(int argc, char const *argv[])
{pthread_t tid;int num = 101;int ret = pthread_create(&tid, NULL, threadFunc, (void*)&num);THREAD_ERROR_CHECK(ret,"pthread_create");printf("I am main thread, tid = %lu\n", pthread_self());sleep(1);return 0;
}
如果将主线程栈帧数据的地址作为参数传递给各个子线程,就一定要注意并发访问的情况,有可能另一个线程的执行会修改掉原本想要传递数据的内容。
线程主动退出
使用 pthread_exit 函数可以主动退出线程,无论这个函数是否是在 start_routine 中被调用,其行为类似于进程退出的 exit 。 pthread_exit 的参数是一个 void * 类型值,它描述了线程的退出状态。在start_routine 中使用return语句可以实现类似的主动退出效果,但是其被动退出的行为有一些问题,所以使用较少。线程的退出状态可以由另一个线程使用 pthread_join 捕获,但是和进程不一样的是,另一个线程的选择是任意的,不需要是线程创建者。
pthread_exit
#include <pthread.h>void pthread_exit(void *retval);
参数
void *retval
该参数是线程的返回值,类型为void *
。其他线程可以通过pthread_join
获取这个返回值。如果线程不需要返回值,可以传入NULL
。
功能
当线程调用
pthread_exit
时,当前线程会立即终止执行。如果线程是被其他线程通过
pthread_join
等待的,pthread_exit
的返回值会传递给pthread_join
。如果线程是分离的(detached),线程资源会在线程终止时自动释放。
返回值
pthread_exit
是一个有去无回的函数,它不会返回。一旦调用,当前线程就会终止。
示例:
void * threadFunc(void *arg){printf("I am child thread, tid = %lu\n", pthread_self());pthread_exit(NULL); //和在线程入口函数return(NULL)等价printf("Can you see me?\n");
}
获取线程退出状态
调用 pthread_join 可以使本线程处于等待状态,直到指定的 thread 终止,就结束等待,并且捕获到的线程终止状态存入 retval 指针所指向的内存空间中。因为线程的终止状态是一个 void * 类型的数据,所以 pthread_join 的调用者往往需要先申请8个字节大小的内存空间,然后将其首地址传入,在pthread_join 的执行之后,这里面的数据会被修改。有些时候,内容可能是一片数据的首地址,还有些情况下内容就是简单的一个8字节的整型。
pthread_join
#include <pthread.h>int pthread_join(pthread_t thread, void **retval);
参数说明
pthread_t thread
指定要等待的线程的 ID。这个 ID 是通过pthread_create
创建线程时返回的。void **retval
用于存储被等待线程的返回值。如果不需要获取返回值,可以传入NULL
。如果需要获取返回值,必须传入一个void *
类型的指针的地址。
返回值
成功时返回
0
。失败时返回错误码(非零值),例如:
EINVAL
:指定的线程 ID 无效。ESRCH
:指定的线程不存在。EINVAL
:指定的线程已经被分离(detached)。
功能
pthread_join
会阻塞调用它的线程,直到目标线程(pthread_t thread
)终止。如果目标线程已经终止,
pthread_join
会立即返回。如果目标线程是分离的(detached),
pthread_join
会失败并返回错误码。
示例:等待线程退出并接收返回数据
void * threadFunc(void *arg){printf("I am child thread, tid = %lu\n",pthread_self());//pthread_exit(NULL);//相当于返回成一个8字节的0char *str = "thread_exit\n";pthread_exit((void *)str);//char *tret = (char *)malloc(20);//strcpy(tret,"hello");//return (void *)tret;
}
int main(){pthread_t tid;int ret = pthread_create(&tid, NULL, threadFunc,NULL);THREAD_ERROR_CHECK(ret, "pthread_create");printf("I am main thread, tid = %lu\n", pthread_self());void *tret;//在调用函数中申请void*变量ret = pthread_join(tid, &tret);//传入void*变量的地址THREAD_ERROR_CHECK(ret, "pthread_join");//printf("tret = %ld\n", (long) tret);printf("tret = %s\n", (char *)tret); //会输出 thread_exit\nreturn 0;
}
注意:
在使用 pthread_join 要特别注意参数的类型, 第一个 thread 参数是不需要取地址的,如果参数错误,有些情况下可能会陷入无限等待,还有些情况会立即终止,触发报错。
ret = pthread_join(tid, &tret);//第一个 thread 参数不需要取地址
线程的取消和资源清理
线程的取消
线程除了可以主动退出以外,还可以被另一个线程终止。不过首先值得注意的是,我们不能轻易地在多线程程序使用信号,因为多线程是共享代码段的,从而信号处理的回调函数也是共享的,当产生一个信号到进程时,进程中用于递送信号的线程是随机的,很有可能会出现主线程因递送信号而终止从而导致所有线程异常退出的情况。
pthread_cancel
pthread_cancel
是 POSIX 线程库(pthread)中的一个函数,用于请求取消(终止)一个正在运行的线程。被取消的线程会清理其资源并退出。
#include <pthread.h>int pthread_cancel(pthread_t thread);
参数说明
pthread_t thread
指定要取消的线程的 ID。这个 ID 是通过pthread_create
创建线程时返回的。
返回值
成功时返回
0
。失败时返回错误码(非零值),例如:
ESRCH
:指定的线程 ID 无效或线程不存在。EINVAL
:指定的线程 ID 不是当前线程或线程已经终止。
功能
pthread_cancel
会向指定的线程发送一个取消请求。线程是否会被取消取决于线程的取消状态和取消类型:
取消状态:线程可以通过
pthread_setcancelstate
设置其取消状态为启用(PTHREAD_CANCEL_ENABLE
,默认值)或禁用(PTHREAD_CANCEL_DISABLE
)。取消类型:线程可以通过
pthread_setcanceltype
设置其取消类型为延迟(PTHREAD_CANCEL_DEFERRED
,默认值)或立即(PTHREAD_CANCEL_ASYNCHRONOUS
)。延迟取消:线程会在下一次调用可取消的函数(如
pthread_join
、pthread_testcancel
等)时响应取消请求。立即取消:线程会立即响应取消请求。
要实现线程的有序退出,线程内部需要实现一种不依赖于信号的机制,这就是线程的取消
pthread_cancel 的工作职责。当一个线程调用 pthread_cancel 去取消另一个线程的时候,另一个线程会将本线程的取消标志位设置为真,此时线程还不会立即取消,当这个线程执行到一些特定的函数之时,线程才会退出。这些会导致已取消未终止的线程终止的函数称为取消点。
在 Linux 中,线程取消点(Cancellation Point)是指线程在执行过程中可以被取消的点。当线程处于取消点时,如果线程的取消状态被设置为允许取消(PTHREAD_CANCEL_ENABLE
),并且取消类型为延迟取消(PTHREAD_CANCEL_DEFERRED
),那么线程可能会在这些点被取消。
以下是一些常见的 Linux 取消点函数,以表格形式列出:
函数名称 | 功能描述 |
---|---|
read() | 从文件描述符读取数据 |
write() | 向文件描述符写入数据 |
open() | 打开文件 |
close() | 关闭文件描述符 |
wait() | 等待子进程结束 |
waitpid() | 等待指定子进程结束 |
waitid() | 等待子进程结束(POSIX 标准) |
recv() | 接收网络数据 |
recvfrom() | 接收网络数据(带源地址) |
recvmsg() | 接收网络数据(带附加信息) |
send() | 发送网络数据 |
sendto() | 发送网络数据(带目标地址) |
sendmsg() | 发送网络数据(带附加信息) |
accept() | 接受网络连接 |
connect() | 建立网络连接 |
select() | 监听多个文件描述符(I/O 多路复用) |
poll() | 监听多个文件描述符(I/O 多路复用) |
epoll_wait() | 等待 epoll 文件描述符的事件 |
usleep() | 使线程休眠指定微秒数 |
nanosleep() | 使线程休眠指定纳秒数 |
pause() | 使线程暂停,直到接收到信号 |
readv() | 从文件描述符读取数据到多个缓冲区 |
writev() | 向文件描述符写入数据从多个缓冲区 |
fread() | 从文件流读取数据 |
fwrite() | 向文件流写入数据 |
fscanf() | 从文件流读取格式化数据 |
fprintf() | 向文件流写入格式化数据 |
fgets() | 从文件流读取一行 |
fputs() | 向文件流写入一行 |
fgetc() | 从文件流读取一个字符 |
fputc() | 向文件流写入一个字符 |
fgetpos() | 获取文件流的当前位置 |
fsetpos() | 设置文件流的当前位置 |
fseek() | 设置文件流的偏移量 |
ftell() | 获取文件流的当前偏移量 |
rewind() | 重置文件流到文件开头 |
fopen() | 打开文件流 |
fclose() | 关闭文件流 |
flockfile() | 锁定文件流 |
funlockfile() | 解锁文件流 |
ftrylockfile() | 尝试锁定文件流 |
pthread_join() | 等待线程结束 |
pthread_cond_wait() | 等待条件变量 |
pthread_cond_timedwait() | 等待条件变量(带超时) |
sem_wait() | 等待信号量 |
sem_timedwait() | 等待信号量(带超时) |
sem_trywait() | 尝试等待信号量 |
mq_receive() | 接收消息队列消息 |
mq_send() | 发送消息队列消息 |
mq_timedreceive() | 接收消息队列消息(带超时) |
mq_timedsend() | 发送消息队列消息(带超时) |
示例:在没有取消点的情况下,主线程命令子线程退出
void *threadFunc(){ //这里为了显示子线程状态,添加printf可能会导致子线程被取消,但目前还没有遇到这种情况printf("I am child thread, tid = %lu\n", pthread_self());while (1){//sleep(1); //sleep是一个取消点函数,把它注释掉看看显现是否会取消}pthread_exit(NULL);
}
int main(int argc, char const *argv[])
{pthread_t tid;int ret = pthread_create(&tid, NULL, threadFunc, NULL);THREAD_ERROR_CHECK(ret, "thread create");printf("I am main thread, tid = %lu\n", pthread_self());pthread_cancel(tid); //命令线程取消pthread_join(tid, NULL);return 0;
}
执行这段代码,可以发现子线程无法被主线程取消掉,如何让sleep函数执行,会导致遇到sleep取消点时而取消子线程。
pthread_testcancel
如果需要手打添加取消点,可以调用 pthread_testcancel 函数。
pthread_testcancel
用于显式地测试当前线程是否有一个未决的取消请求。如果存在未决的取消请求,pthread_testcancel
会触发取消操作,使线程退出。如果没有未决的取消请求,该函数不会有任何操作。
#include <pthread.h>void pthread_testcancel(void);
功能
pthread_testcancel
用于测试当前线程是否有未决的取消请求。如果线程的取消状态是启用(
PTHREAD_CANCEL_ENABLE
),并且取消类型是延迟(PTHREAD_CANCEL_DEFERRED
),那么调用pthread_testcancel
会检查是否有未决的取消请求。如果存在未决的取消请求,线程会执行取消操作,调用清理函数(如果有),并退出。
如果没有未决的取消请求,函数不会有任何操作。
使用场景
pthread_testcancel
通常用于以下场景:
延迟取消类型:当线程的取消类型是延迟(
PTHREAD_CANCEL_DEFERRED
)时,线程不会立即响应取消请求,而是会在调用可取消的函数时响应。pthread_testcancel
是一个显式的可取消点,可以用来主动检查取消请求。长时间运行的循环:在长时间运行的循环中,调用
pthread_testcancel
可以确保线程能够及时响应取消请求,避免线程在取消请求发出后仍然长时间运行。
线程资源清理
在引入线程取消之后,程序员在管理资源回收的难度上会急剧提升。为了简化资源清理行为,线程库引入了 pthread_cleanup_push 和 pthread_cleanup_pop 函数来管理线程主动或者被动终止时所申请资源(比如文件、堆空间、锁等等)。
pthread_cleanup_push
pthread_cleanup_push
用于注册一个清理函数(cleanup handler)。当线程被取消(通过 pthread_cancel
)或调用 pthread_exit
时,注册的清理函数会被自动调用。这有助于线程在退出时安全地清理资源,例如释放动态分配的内存、关闭文件描述符等。
#include <pthread.h>void pthread_cleanup_push(void (*routine)(void *), void *arg);
参数说明
void (*routine)(void *)
指定清理函数的指针。清理函数必须接受一个void *
类型的参数,并且没有返回值。void *arg
传递给清理函数的参数。清理函数可以通过这个参数接收额外的信息,用于接收需要清理的数据
功能
pthread_cleanup_push
用于注册一个清理函数。注册的清理函数会在以下情况下被调用:
线程被取消(通过
pthread_cancel
)。线程调用
pthread_exit
。线程正常结束(通过返回值退出)。
清理函数的调用顺序是后进先出(LIFO),即最后注册的清理函数会最先被调用。
pthread_cleanup_push
和 pthread_cleanup_pop
必须成对出现。pthread_cleanup_push
用于注册清理函数,而 pthread_cleanup_pop
用于取消注册。pthread_cleanup_pop
的第二个参数决定是否调用清理函数。
pthread_cleanup_pop(0)
:取消注册清理函数,但不调用它。pthread_cleanup_pop(1)
:取消注册清理函数,并调用它。
pthread_cleanup_pop
pthread_cleanup_pop
用于取消注册之前通过 pthread_cleanup_push
注册的清理函数。
#include <pthread.h>void pthread_cleanup_pop(int execute);
参数说明
int execute
一个布尔值,用于决定是否调用清理函数:0
:取消注册清理函数,但不调用它。1
:取消注册清理函数,并调用它。
功能
取消注册:
pthread_cleanup_pop
用于取消之前通过pthread_cleanup_push
注册的清理函数。调用清理函数:根据
execute
参数的值,可以选择是否调用清理函数。成对使用:
pthread_cleanup_push
和pthread_cleanup_pop
必须成对出现,否则会导致未定义行为。
示例:
void cleanup(void *p){free(p);printf("I am cleanup\n");
}
void * threadFunc(void *arg){void *p = malloc(100000);pthread_cleanup_push(cleanup,p);//一定要在cancel点之前pushprintf("I am child thread, tid = %lu\n",pthread_self());//pthread_exit((void *)0);//在pop之前exit,cleanup弹栈并被调用pthread_cleanup_pop(1);//在exit之后pop cleanup弹栈,如果参数为1被调用pthread_exit((void *)0);
}
POSIX要求 pthread_cleanup_push 和 pthread_cleanup_pop 必须成对出现在同一个作用域当中,主要是为了约束程序员在清理函数当中行为。下面是在 /urs/include/pthread.h 文件当中,线程清理函数的定义:
#define pthread_cleanup_push(routine, arg) \
do {
\
__pthread_cleanup_class __clframe (routine, arg)
/* Remove a cleanup handler installed by the matching pthread_cleanup_push.
If EXECUTE is non-zero, the handler function is called. */
#define pthread_cleanup_pop(execute) \
__clframe.__setdoit (execute);
} while (0)
可以发现push和pop的宏定义不是语义完全的,它们必须在同一作用域中成对出现才能使花括号成功匹配。
下面是压入多个清理函数的例子:
void cleanup(void *p){printf("clean up, %s\n", (char *)p);
}
void * threadFunc(void *arg){pthread_cleanup_push(cleanup,(void *)"first");pthread_cleanup_push(cleanup,(void *)"second");//pthread_exit((void *)0);return (void *)0;pthread_cleanup_pop(1);pthread_cleanup_pop(1);
}
int main(){pthread_t tid;int ret;ret = pthread_create(&tid,NULL,threadFunc,NULL);THREAD_ERROR_CHECK(ret,"pthread_create");ret = pthread_join(tid,NULL);THREAD_ERROR_CHECK(ret,"pthread_join");return 0;
}
线程的同步和互斥(重点)
由于多线程之间不存在隔离,共享同一个地址在提高运行效率的同时也给用户带来了巨大的困扰。在并发执行的情况下,大量的共享资源成为竞争条件,导致程序执行的结果往往和预期的内容大相径庭,如果一个程序的结果是不正确的,那么再高的效率也毫无意义。在基于之前进程对并发的研究之上,线程库也提供了专门用于正确地访问共享资源的机制。
互斥锁的基本使用
在多线程编程中,用来控制共享资源的最简单有效也是最广泛使用的机制就是 mutex(MUTual
EXclusion) ,即互斥锁。锁的数据类型是 pthread_mutex_t ,其本质是一个全局的标志位,线程可以对作原子地测试并修改,即所谓的加锁。当一个线程持有锁的时候,其余线程再尝试加锁时(包括自己再次加锁),会使自己陷入阻塞状态,直到锁被持有线程解锁才能恢复运行。所以锁在某个时刻永远不能被两个线程同时持有。
创建锁有两种形式:直接用 PHTREAD_MUTEX_INITIALIZER 初始化一个 pthread_mutex_t 类型的变量,即静态初始化锁;而使用 pthread_mutex_init 函数可以动态创建一个锁。动态创建锁的方式更加常见。使用 pthread_mutex_destory 可以销毁一个锁。
pthread_mutex_t fastmutex = PTHREAD_MUTEX_INITIALIZER;
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t
*mutexattr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
pthread_mutex_init
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
动态初始化互斥锁。
pthread_mutex_t *mutex
:指向要初始化的互斥锁的指针。const pthread_mutexattr_t *mutexattr
:指向互斥锁属性的指针。如果为NULL
,则使用默认属性。
返回值
成功时返回
0
。失败时返回错误码。
pthread_mutex_destroy
int pthread_mutex_destroy(pthread_mutex_t *mutex);
销毁互斥锁,释放与互斥锁相关的资源。
pthread_mutex_t *mutex
:指向要销毁的互斥锁的指针。
返回值
成功时返回
0
。失败时返回错误码。
pthread_mutex_lock
int pthread_mutex_lock(pthread_mutex_t *mutex);
加锁操作,阻塞当前线程直到获取互斥锁。
pthread_mutex_t *mutex
:指向要加锁的互斥锁的指针。
返回值
成功时返回
0
。失败时返回错误码。
如果互斥锁已经被其他线程持有,调用线程会阻塞,直到互斥锁被释放。
同一线程多次调用
pthread_mutex_lock
会导致死锁,除非使用的是递归锁(通过pthread_mutexattr_settype
设置为PTHREAD_MUTEX_RECURSIVE
)。
pthread_mutex_unlock
int pthread_mutex_unlock(pthread_mutex_t *mutex);
解锁操作,释放互斥锁。
pthread_mutex_t *mutex
:指向要解锁的互斥锁的指针。
返回值
成功时返回
0
。失败时返回错误码。
pthread_mutex_trylock
int pthread_mutex_trylock(pthread_mutex_t *mutex);
尝试加锁,不会阻塞。如果互斥锁已经被其他线程持有,立即返回。
pthread_mutex_t *mutex
:指向要尝试加锁的互斥锁的指针。
返回值
如果成功获取锁,返回
0
。如果互斥锁已经被其他线程持有,返回
EBUSY
。其他错误返回相应的错误码。
注意事项
pthread_mutex_trylock
不会阻塞,因此适用于需要非阻塞加锁的场景。如果互斥锁已经被其他线程持有,调用线程不会等待,而是立即返回。
下面是最基本的使用锁的例子,编程规范要求:谁加锁,谁解锁。锁的使用纷繁复杂,如果不按照规范行事,一方面会很容易出现错误,并且出错之后很难排查,另一方面,随意解锁会导致代码的可读性极差,无法后续调整优化。
示例:
typedef struct shareRes_s{pthread_mutex_t mutex;
} shareRes_t, *pShareRes_t;
void *threadFunc(void *arg){shareRes_t *shared = (shareRes_t *)arg;pthread_mutex_lock(&shared->mutex);puts("I am child thread");pthread_mutex_unlock(&shared->mutex);
}
int main(){shareRes_t shared;pthread_t tid;pthread_mutex_init(&shared.mutex,NULL);pthread_create(&tid,NULL,threadFunc,&shared);sleep(1);pthread_join(tid,NULL);pthread_mutex_lock(&shared.mutex);puts("I am main thread");pthread_mutex_unlock(&shared.mutex);return 0;
}
使用互斥锁访问共享资源
typedef struct shareRes_s{pthread_mutex_t mutex;int val;
}shareRes_t, *pshareRes_t;
void *threadFunc(void *arg){pshareRes_t share = (pshareRes_t)arg;for(int i = 0; i < 100; i++){pthread_mutex_lock(&share->mutex);share->val++;pthread_mutex_unlock(&share->mutex);}pthread_exit(NULL);
}int main(int argc, char const *argv[])
{pthread_t tid;shareRes_t share;share.val = 0;int ret = pthread_mutex_init(&share.mutex, NULL);THREAD_ERROR_CHECK(ret, "pthread_mutex_init");ret = pthread_create(&tid, NULL, threadFunc, &share);THREAD_ERROR_CHECK(ret, "pthread_create");for(int i = 0; i < 100; i++){pthread_mutex_lock(&share.mutex);share.val++;pthread_mutex_unlock(&share.mutex);}ret = pthread_join(tid, NULL);THREAD_ERROR_CHECK(ret, "pthread_join");ret = pthread_mutex_destroy(&share.mutex);THREAD_ERROR_CHECK(ret, "pthread_mutex_destroy");printf("val = %d\n", share.val);return 0;
}
另外就是要注意到,使用线程互斥量的效率要比之前的进程间通信信号量机制好很多,所以实际工作中几乎都是使用线程互斥锁来实现互斥。
死锁
使用互斥锁的时候必须小心谨慎,如果是需要持续多个锁的情况,加锁和解锁之间必须要维持一定的顺序。即使是只有一把锁,如果使用不当,也会导致死锁。当一个线程持有了锁以后,又试图对同一把锁加锁时,线程会陷入死锁状态。
一个很简单的例子就是:有一个苹果和一个橘子,两个孩子一个拥有苹果另一个拥有橘子,但是他们都想拥有两个水果才开吃。但彼此又不愿放弃手中的水果,最终变得僵持不下。
可以使用 pthread_mutexattr_settype 函数修改锁的属性,检错锁在重复加锁是会报错,递归锁或者称为可重入锁在重复加锁不会死锁时,只是会增加锁的引用计数,解锁时也只是减少锁的引用计数。但是在实际工作中,如果一个设计必须依赖于递归锁,那么这个设计肯定是有问题的。
pthread_mutexattr_t
用于表示互斥锁属性的类型。互斥锁属性用于在创建互斥锁时指定其行为和特性。通过设置互斥锁属性,可以控制互斥锁的类型、协议、优先级继承等行为。
互斥锁属性的作用
互斥锁属性允许在创建互斥锁时指定以下特性:
互斥锁类型:普通互斥锁、递归互斥锁、错误检查互斥锁等。
互斥锁协议:优先级继承、优先级保护等。
互斥锁优先级天花板:用于实时线程的优先级管理。
互斥锁共享性:互斥锁是否可以在多个进程之间共享。
初始化互斥锁属性
互斥锁属性需要先初始化,然后可以设置或获取其属性值。
#include <pthread.h>int pthread_mutexattr_init(pthread_mutexattr_t *attr);
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
pthread_mutexattr_init
:初始化互斥锁属性对象。pthread_mutexattr_destroy
:销毁互斥锁属性对象,释放相关资源。
返回值
成功时返回
0
。失败时返回错误码。
设置和获取互斥锁属性
以下是一些常用的设置和获取互斥锁属性的函数:
设置互斥锁类型
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
int pthread_mutexattr_gettype(const pthread_mutexattr_t *attr, int *type);
const pthread_mutexattr_t *attr
:指向互斥锁属性对象的指针。
int *type
:用于存储互斥锁类型的指针。
type
:互斥锁类型,可以是以下值之一:
PTHREAD_MUTEX_NORMAL
描述:普通互斥锁。
行为:
同一线程多次调用
pthread_mutex_lock
会导致死锁。如果线程尝试解锁一个它未持有的互斥锁,行为未定义。
用途:适用于大多数需要互斥保护的场景,是最常用的互斥锁类型。
PTHREAD_MUTEX_RECURSIVE
描述:递归互斥锁。
行为:
同一线程可以多次加锁同一个互斥锁,每次加锁必须对应一次解锁。
互斥锁的计数器会记录加锁的次数,只有当计数器归零时,互斥锁才会真正释放。
如果线程尝试解锁一个它未持有的互斥锁,会返回错误。
用途:适用于需要在递归函数中多次加锁的场景。
PTHREAD_MUTEX_ERRORCHECK
描述:错误检查互斥锁。
行为:
如果线程尝试加锁一个它已经持有的互斥锁,会返回错误。
如果线程尝试解锁一个它未持有的互斥锁,会返回错误。
适用于调试和错误检查,可以帮助检测互斥锁的使用错误。
用途:适用于开发和调试阶段,帮助发现潜在的互斥锁使用问题。
PTHREAD_MUTEX_DEFAULT
描述:默认互斥锁类型。
行为:与
PTHREAD_MUTEX_NORMAL
相同。用途:如果不显式设置互斥锁类型,互斥锁会使用默认类型。
设置互斥锁协议
int pthread_mutexattr_setprotocol(pthread_mutexattr_t *attr, int protocol);
int pthread_mutexattr_getprotocol(const pthread_mutexattr_t *attr, int *protocol);
const pthread_mutexattr_t *attr
:指向互斥锁属性对象的指针。
int *protocol
:用于存储互斥锁协议的指针。
protocol
:互斥锁协议,可以是以下值之一:
PTHREAD_PRIO_NONE
描述:无优先级继承。
行为:
线程在获取互斥锁时,不会改变其优先级。
适用于大多数不需要优先级管理的场景。
用途:默认协议,适用于大多数互斥锁。
PTHREAD_PRIO_INHERIT
描述:优先级继承。
行为:
当一个低优先级线程持有互斥锁时,如果高优先级线程尝试获取该互斥锁,低优先级线程会继承高优先级线程的优先级。
低优先级线程释放互斥锁后,会恢复其原始优先级。
用于避免优先级反转问题。
用途:适用于需要避免优先级反转的实时系统。
PTHREAD_PRIO_PROTECT
描述:优先级保护。
行为:
互斥锁有一个固定的优先级天花板(
prioceiling
),线程在获取互斥锁时会提升到该优先级。互斥锁的优先级天花板必须在初始化时设置。
适用于需要严格控制优先级的实时系统。
用途:适用于需要严格控制优先级的实时系统。
设置互斥锁优先级天花板
互斥锁的优先级天花板(Priority Ceiling)是与互斥锁协议相关的一个概念,主要用于实时系统中避免优先级反转问题。优先级天花板是一个固定的优先级值,当线程尝试获取互斥锁时,线程的优先级会被提升到这个天花板值。
优先级天花板的作用
优先级天花板的主要作用是避免优先级反转问题。优先级反转是指一个高优先级线程被一个低优先级线程阻塞,而低优先级线程又无法及时释放资源,导致高优先级线程无法及时运行。优先级天花板通过提升线程的优先级,确保高优先级线程能够尽快获取互斥锁。
可以通过 pthread_mutexattr_setprioceiling
函数设置互斥锁的优先级天花板:
int pthread_mutexattr_setprioceiling(pthread_mutexattr_t *attr, int prioceiling);
pthread_mutexattr_t *attr
:指向互斥锁属性对象的指针。int prioceiling
:优先级天花板值,通常是一个优先级值。
可以通过 pthread_mutexattr_getprioceiling
函数获取互斥锁的优先级天花板:
int pthread_mutexattr_getprioceiling(const pthread_mutexattr_t *attr, int *prioceiling);
const pthread_mutexattr_t *attr
:指向互斥锁属性对象的指针。int *prioceiling
:用于存储优先级天花板值的指针。
优先级范围:优先级天花板值必须在系统支持的优先级范围内。可以通过
sched_get_priority_min
和sched_get_priority_max
获取系统支持的优先级范围。协议设置:优先级天花板只在
PTHREAD_PRIO_PROTECT
协议下有效。如果使用PTHREAD_PRIO_NONE
或PTHREAD_PRIO_INHERIT
协议,优先级天花板不会生效。实时调度策略:优先级天花板通常用于实时调度策略(如
SCHED_FIFO
或SCHED_RR
)。如果线程使用默认的调度策略(如SCHED_OTHER
),优先级天花板可能不会生效。线程优先级提升:当线程获取互斥锁时,其优先级会被提升到天花板值。释放互斥锁后,线程的优先级会恢复到原始值。
设置互斥锁共享性
互斥锁的共享性(shared attribute)决定了互斥锁是否可以在多个进程之间共享。在 POSIX 线程库(pthread)中,互斥锁的共享性有两种设置:进程内共享(PTHREAD_PROCESS_PRIVATE
)和跨进程共享(PTHREAD_PROCESS_SHARED
)。
互斥锁的共享性决定了互斥锁的作用范围:
进程内共享(
PTHREAD_PROCESS_PRIVATE
):互斥锁仅在创建它的进程内有效,不能被其他进程访问。跨进程共享(
PTHREAD_PROCESS_SHARED
):互斥锁可以在多个进程之间共享,多个进程可以使用同一个互斥锁进行同步。
可以通过 pthread_mutexattr_setpshared
函数设置互斥锁的共享性:
int pthread_mutexattr_setpshared(pthread_mutexattr_t *attr, int pshared);
pthread_mutexattr_t *attr
:指向互斥锁属性对象的指针。int pshared
:共享性属性,可以是以下值之一:PTHREAD_PROCESS_PRIVATE
:互斥锁仅在创建它的进程内有效。PTHREAD_PROCESS_SHARED
:互斥锁可以在多个进程之间共享。
可以通过 pthread_mutexattr_getpshared
函数获取互斥锁的共享性:
int pthread_mutexattr_getpshared(const pthread_mutexattr_t *attr, int *pshared);
const pthread_mutexattr_t *attr
:指向互斥锁属性对象的指针。int *pshared
:用于存储共享性属性的指针。
默认共享性:如果不显式设置互斥锁的共享性,互斥锁会使用默认值
PTHREAD_PROCESS_PRIVATE
,即仅在创建它的进程内有效。跨进程共享的限制:
要使用跨进程共享的互斥锁,必须确保互斥锁存储在多个进程可以访问的共享内存中(例如,使用
mmap
或shmget
创建的共享内存)。跨进程共享的互斥锁需要更多的系统资源和同步机制,因此性能可能会略低于进程内共享的互斥锁。
跨进程共享的用途:适用于需要在多个进程之间同步访问共享资源的场景,例如多进程服务器或分布式系统。
现在我们演示一个递归锁的例子:
typedef struct shareRes_s{pthread_mutex_t mutex;
} shareRes_t, *pShareRes_t;
void *threadFunc(void *arg){pShareRes_t p = (pShareRes_t)arg;pthread_mutex_lock(&p->mutex);puts("fifth");pthread_mutex_unlock(&p->mutex);
}
int main(){shareRes_t shared;int ret;pthread_mutexattr_t mutexattr;pthread_mutexattr_init(&mutexattr);//ret = pthread_mutexattr_settype(&mutexattr,PTHREAD_MUTEX_ERRORCHECK);ret = pthread_mutexattr_settype(&mutexattr,PTHREAD_MUTEX_RECURSIVE);THREAD_ERROR_CHECK(ret,"pthread_mutexattr_settype");pthread_t tid;pthread_create(&tid,NULL,threadFunc,(void *)&shared);pthread_mutex_init(&shared.mutex,&mutexattr);ret = pthread_mutex_lock(&shared.mutex);THREAD_ERROR_CHECK(ret,"pthread_mute_lock 1");puts("first");ret = pthread_mutex_lock(&shared.mutex);THREAD_ERROR_CHECK(ret,"pthread_mute_lock 2");puts("second");pthread_mutex_unlock(&shared.mutex);puts("third");pthread_mutex_unlock(&shared.mutex);//两次加锁,要有两次解锁puts("forth");pthread_join(tid,NULL);return 0;
}
输出结果:
(base) ubuntu@ubuntu:~/MyProject/Linux/thread$ ./thread6
first
second
third
forth
fifth
同步和条件变量
理论上来说,利用互斥锁可以解决所有的同步问题,但是生产实践之中往往会出现这样的问题:一个线程能否执行取决于业务的状态,而该状态是多线程共享的,状态的数值会随着程序的运行不断地变化,线程也经常在可运行和不可运行之间动态切换。假如单纯使用互斥锁来解决问题的话,就会出现大量的重复的“加锁-检查条件不满足-解锁”的行为,这样的话,不满足条件的线程会经常试图占用CPU资源,上下文切换也会非常频繁。
对于这样依赖于共享资源这种条件来控制线程之间的同步的问题,我们希望采用一种无竞争的方式让多个线程在共享资源处会和——这就是条件变量。当涉及到同步问题时,业务上需要设计一个状态/条件(一般是一个标志位)来表示该线程到底是可运行还是不可运行的,这个状态是多线程共享的,故修改的时候必须加锁访问,这就意味着条件变量一定要配合锁来使用。条件变量是一种“等待-唤醒”机制:线程运行的时候发现不满足执行的状态可以等待,线程运行的时候如果修改了状态可以唤醒其他线程。
接下来我们来举一个条件变量的例子。
业务场景:假设有两个线程t1和t2并发运行,t1会执行A事件,t2会执行B事件,现在业务要求,无论t1或t2哪个线程先占用CPU,总是需要满足A先B后的同步顺序。
解决方案:
- 在t1和t2线程执行之前,先初始化状态为B不可执行。
- t1线程执行A事件,执行完成以后先加锁,修改状态为B可执行,并唤醒条件变量,然后解锁(解锁和唤醒这两个操作可以交换顺序);
- t2线程先加锁,然后检查状态:假如B不可执行,则B阻塞在条件变量上,当t2阻塞在条件变量以后,t2线程会解锁并陷入阻塞状态直到t1线程唤醒为止,t2被唤醒以后,会先加锁。接下来t2线程处于加锁状态,可以在解锁之后,再来执行B事件;而假如状态为B可执行,则t2线程处于加锁状态继续执行后续代码,也可以在解锁之后,再来执行B事件。
通过上面的设计,可以保证无论t1和t2按照什么样的顺序交织执行,A事件总是先完成,B事件总是后完成——即使是比较极端的情况也是如此:比如t1一直占用CPU直到结束,那么t2占用CPU时,状态一定是B可执行,则不会陷入等待;又比如t2先一直占用CPU,t2检查状态时会发现状态为B不可执行,就会阻塞在条件变量之上,这样就要等到A执行完成以后,才能醒来继续运行。
下面是具体条件变量相关接口:
pthread_cond_t
pthread_cond_t
是用于表示条件变量的类型,而 PTHREAD_COND_INITIALIZER
是一个宏,用于静态初始化条件变量。这种初始化方式适用于全局或静态存储期的条件变量。
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_cond_t
:条件变量的类型。PTHREAD_COND_INITIALIZER
:用于静态初始化条件变量的宏。
pthread_cond_init
如果条件变量是动态分配的或需要非默认属性,可以使用 pthread_cond_init
函数动态初始化条件变量。
#include <pthread.h>int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *cond_attr);
pthread_cond_t *cond
:指向要初始化的条件变量的指针。const pthread_condattr_t *cond_attr
:指向条件变量属性的指针。如果为NULL
,则使用默认属性。
返回值
成功时返回
0
。失败时返回错误码。
pthread_cond_signal
pthread_cond_signal
用于唤醒一个正在等待该条件变量的线程。
#include <pthread.h>int pthread_cond_signal(pthread_cond_t *cond);
参数说明
pthread_cond_t *cond
:指向要发送信号的条件变量的指针。
功能
pthread_cond_signal
用于唤醒一个正在等待指定条件变量的线程。如果有多个线程正在等待同一个条件变量,
pthread_cond_signal
只会唤醒其中一个线程。如果没有线程正在等待该条件变量,
pthread_cond_signal
不会做任何操作。
返回值
成功时返回
0
。失败时返回错误码:
EINVAL
:指定的条件变量无效。
注意事项
互斥锁的使用:
pthread_cond_signal
通常与互斥锁一起使用,以确保线程安全。在调用pthread_cond_signal
之前,必须先锁定互斥锁。唤醒单个线程:
pthread_cond_signal
只会唤醒一个等待条件变量的线程。如果有多个线程在等待,只有一个线程会被唤醒。避免竞态条件:在调用
pthread_cond_signal
之前,必须确保条件已经满足,否则可能会导致竞态条件。线程安全:
pthread_cond_signal
是线程安全的,但需要确保互斥锁的正确使用。
pthread_cond_broadcast
pthread_cond_broadcast
用于唤醒所有正在等待某个条件变量的线程。与 pthread_cond_signal
不同,pthread_cond_broadcast
会唤醒所有等待该条件变量的线程,而不仅仅是其中一个。
#include <pthread.h>int pthread_cond_broadcast(pthread_cond_t *cond);
参数说明
pthread_cond_t *cond
:指向要发送广播信号的条件变量的指针。
功能
pthread_cond_broadcast
用于唤醒所有正在等待指定条件变量的线程。如果没有线程正在等待该条件变量,
pthread_cond_broadcast
不会做任何操作。
返回值
成功时返回
0
。失败时返回错误码:
EINVAL
:指定的条件变量无效。
pthread_cond_wait
pthread_cond_wait
用于使当前线程等待某个条件变量被唤醒。它通常与互斥锁一起使用,以确保线程安全。以下是 pthread_cond_wait
的详细使用方法:
#include <pthread.h>int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
参数说明
pthread_cond_t *cond
:指向条件变量的指针。pthread_mutex_t *mutex
:指向互斥锁的指针。这个互斥锁必须在调用pthread_cond_wait
之前被当前线程锁定。
功能
pthread_cond_wait
使当前线程等待条件变量cond
被唤醒。在调用
pthread_cond_wait
时,当前线程必须已经锁定了互斥锁mutex
。当线程调用
pthread_cond_wait
后,互斥锁会被原子性地解锁,线程进入等待状态。当条件变量被唤醒(通过
pthread_cond_signal
或pthread_cond_broadcast
)时,线程会被唤醒,并重新锁定互斥锁。如果条件变量没有被唤醒,线程会一直等待。
返回值
成功时返回
0
。失败时返回错误码:
EINVAL
:指定的条件变量或互斥锁无效。EDEADLK
:互斥锁没有被当前线程锁定。
pthread_cond_timewait
pthread_cond_timedwait
与 pthread_cond_wait
类似,但允许线程在等待条件变量时设置一个超时时间。如果在指定的时间内条件变量没有被唤醒,线程将从等待状态中退出。这使得 pthread_cond_timedwait
非常适合需要超时机制的场景。
#include <pthread.h>
#include <time.h>int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime);
参数说明
pthread_cond_t *cond
:指向条件变量的指针。pthread_mutex_t *mutex
:指向互斥锁的指针。这个互斥锁必须在调用pthread_cond_timedwait
之前被当前线程锁定。const struct timespec *abstime
:指向绝对超时时间的指针。abstime
是一个struct timespec
类型的变量,表示从 1970 年 1 月 1 日 00:00:00 UTC 开始的绝对时间。
功能
pthread_cond_timedwait
使当前线程等待条件变量cond
被唤醒,或者直到指定的超时时间abstime
到来。在调用
pthread_cond_timedwait
时,当前线程必须已经锁定了互斥锁mutex
。当线程调用
pthread_cond_timedwait
时,互斥锁会被原子性地解锁,线程进入等待状态。如果条件变量在超时时间内被唤醒(通过
pthread_cond_signal
或pthread_cond_broadcast
),线程会被唤醒,并重新锁定互斥锁。如果超时时间到达,线程也会从等待状态中退出,并重新锁定互斥锁。
返回值
成功时返回
0
。失败时返回错误码:
ETIMEDOUT
:超时时间到达,线程从等待状态中退出。EINVAL
:指定的条件变量或互斥锁无效。EDEADLK
:互斥锁没有被当前线程锁定。EINVAL
:abstime
指定的时间无效(例如,tv_nsec
超出范围)。
pthread_cond_destroy
pthread_cond_destroy
用于释放与条件变量相关的资源,确保条件变量在不再使用时被正确清理。
#include <pthread.h>int pthread_cond_destroy(pthread_cond_t *cond);
参数说明
pthread_cond_t *cond
:指向要销毁的条件变量的指针。
功能
pthread_cond_destroy
用于销毁条件变量,释放与条件变量相关的资源。在销毁条件变量之前,必须确保没有线程正在等待该条件变量。否则,行为未定义,可能会导致程序崩溃或其他未定义行为。
返回值
成功时返回
0
。失败时返回错误码:
EBUSY
:有线程正在等待该条件变量。EINVAL
:指定的条件变量无效。
现在我们对之前A和B的同步事件进行实现:
typedef struct shareRes_s{int flag;pthread_mutex_t mutex;pthread_cond_t cond;}shareRes_t;
void A(){printf("Before A\n");sleep(3);printf("After A\n");
}
void B(){printf("Before B\n");sleep(3);printf("After B\n");
}
void *threadFunc(void *arg){sleep(3);shareRes_t * pshareRes = (shareRes_t *)arg;pthread_mutex_lock(&pshareRes->mutex);if(pshareRes->flag != 1){pthread_cond_wait(&pshareRes->cond, &pshareRes->mutex);}pthread_mutex_unlock(&pshareRes->mutex);B();pthread_exit(NULL);
}
int main(int argc, char const *argv[])
{shareRes_t shareRes;shareRes.flag = 0;pthread_t tid;pthread_mutex_init(&shareRes.mutex,NULL);pthread_cond_init(&shareRes.cond, NULL);pthread_create(&tid, NULL, threadFunc, &shareRes);A();pthread_mutex_lock(&shareRes.mutex);shareRes.flag = 1;pthread_cond_signal(&shareRes.cond);pthread_mutex_unlock(&shareRes.mutex);pthread_join(tid, NULL);return 0;
}
实战:利用同步和互斥实现售票放票功能
typedef struct shareRes_s{int val;pthread_mutex_t mutex;pthread_cond_t cond;
}shareRes_t;void *SellTicket(void *arg){sleep(1);shareRes_t *pshareRes = (shareRes_t *)arg;while (1){pthread_mutex_lock(&pshareRes->mutex);if(pshareRes->val > 0){printf("Before 1 sells tickets, ticketNum = %d\n", pshareRes->val);pshareRes->val--;if(pshareRes->val == 0){pthread_cond_signal(&pshareRes->cond);}usleep(500000);printf("After 1 sells tickets, ticketNum = %d\n", pshareRes->val);pthread_mutex_unlock(&pshareRes->mutex);usleep(200000);//等待一会,让setTicket抢到锁}else{pthread_mutex_unlock(&pshareRes->mutex);break;}}pthread_exit(NULL);}
void *ProduceTicket(void *arg){shareRes_t *pshareRes = (shareRes_t *)arg;pthread_mutex_lock(&pshareRes->mutex);if(pshareRes->val > 0){printf("set is waiting\n");int ret = pthread_cond_wait(&pshareRes->cond,&pshareRes->mutex);THREAD_ERROR_CHECK(ret, "pthread_cond_wait");}printf("add tickets\n");pshareRes->val = 10;pthread_mutex_unlock(&pshareRes->mutex);pthread_exit(NULL);
}
int main(int argc, char const *argv[])
{shareRes_t share;share.val = 20;int ret = pthread_mutex_init(&share.mutex, NULL);THREAD_ERROR_CHECK(ret,"pthread_mutex_init");ret = pthread_cond_init(&share.cond, NULL);THREAD_ERROR_CHECK(ret,"pthread_cond_init");pthread_t tidS;pthread_t tidP;ret = pthread_create(&tidS, NULL, SellTicket, &share);THREAD_ERROR_CHECK(ret,"pthread_create_tids");pthread_create(&tidP, NULL, ProduceTicket, &share);THREAD_ERROR_CHECK(ret,"pthread_create_tidp");pthread_join(tidS, NULL);pthread_join(tidP, NULL);pthread_cond_destroy(&share.cond);pthread_cond_destroy(&share.cond);return 0;
}
输出结果:
(base) ubuntu@ubuntu:~/MyProject/Linux/thread$ ./thread8
set is waiting
Before 1 sells tickets, ticketNum = 20
After 1 sells tickets, ticketNum = 19
Before 1 sells tickets, ticketNum = 19
After 1 sells tickets, ticketNum = 18
Before 1 sells tickets, ticketNum = 18
........
After 1 sells tickets, ticketNum = 1
Before 1 sells tickets, ticketNum = 1
After 1 sells tickets, ticketNum = 0
add tickets
Before 1 sells tickets, ticketNum = 10
After 1 sells tickets, ticketNum = 9
Before 1 sells tickets, ticketNum = 9
After 1 sells tickets, ticketNum = 8
Before 1 sells tickets, ticketNum = 8
........
After 1 sells tickets, ticketNum = 1
Before 1 sells tickets, ticketNum = 1
After 1 sells tickets, ticketNum = 0
实战:利用互斥锁和条件变量实现简单的生产者和消费者问题
typedef struct shareRes_s{int val;pthread_mutex_t mutex;pthread_cond_t full;pthread_cond_t empty;
}shareRes_t;
void *Consumer(void *arg){shareRes_t *pshare = (shareRes_t *)arg;while (1){pthread_mutex_lock(&pshare->mutex);if(pshare->val > 0){pshare->val--;printf("consume a product current is %d\n",pshare->val);pthread_cond_signal(&pshare->empty);}else{printf("wait to produce current is %d\n",pshare->val);pthread_cond_wait(&pshare->full, &pshare->mutex);}pthread_mutex_unlock(&pshare->mutex);usleep(200000);}pthread_exit(NULL);
}
void *Producer(void *arg){shareRes_t *pshare = (shareRes_t *)arg;while (1){pthread_mutex_lock(&pshare->mutex);if(pshare->val < 5){pshare->val++;printf("produce a product current is %d\n",pshare->val);pthread_cond_signal(&pshare->full);}else{printf("wait to consume current is %d\n",pshare->val);pthread_cond_wait(&pshare->empty, &pshare->mutex);}pthread_mutex_unlock(&pshare->mutex);usleep(500000);}pthread_exit(NULL);
}
int main(int argc, char const *argv[])
{shareRes_t share;pthread_mutex_init(&share.mutex, NULL);pthread_cond_init(&share.empty, NULL);pthread_cond_init(&share.empty, NULL);share.val = 5;pthread_t tidc, tidp;pthread_create(&tidc, NULL, Consumer, &share);pthread_create(&tidp, NULL, Producer, &share);pthread_join(tidc, NULL);pthread_join(tidp, NULL);return 0;
}
线程的属性
在线程创建的时候,用户可以给线程指定一些属性,用来控制线程的调度情况、CPU绑定情况、屏障、线程调用栈和线程分离等属性。这些属性可以通过一个 pthread_attr_t 类型的变量来控制,可以使用pthread_attr_set 系列设置属性,然后可以传入 pthread_create 函数,从控制新建线程的属性。
pthread_attr_init
初始化线程属性对象。
int pthread_attr_init(pthread_attr_t *attr);
pthread_attr_t *attr
:指向线程属性对象的指针。返回值:成功时返回
0
,失败时返回错误码。
pthread_attr_destroy
销毁线程属性对象,释放相关资源。
int pthread_attr_destroy(pthread_attr_t *attr);
pthread_attr_t *attr
:指向线程属性对象的指针。返回值:成功时返回
0
,失败时返回错误码。
pthread_attr_setdetachstate
设置线程的分离状态。
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
pthread_attr_t *attr
:指向线程属性对象的指针。int detachstate
:线程的分离状态,可以是PTHREAD_CREATE_DETACHED
或PTHREAD_CREATE_JOINABLE
。返回值:成功时返回
0
,失败时返回错误码。
pthread_attr_setstacksize
设置线程的堆栈大小。
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
pthread_attr_t *attr
:指向线程属性对象的指针。size_t stacksize
:线程的堆栈大小。返回值:成功时返回
0
,失败时返回错误码。
pthread_attr_setstack
设置线程的堆栈地址和大小。
int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
pthread_attr_t *attr
:指向线程属性对象的指针。void *stackaddr
:堆栈的起始地址。size_t stacksize
:堆栈的大小。返回值:成功时返回
0
,失败时返回错误码。
pthread_attr_setschedpolicy
设置线程的调度策略。
int pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy);
pthread_attr_t *attr
:指向线程属性对象的指针。int policy
:调度策略,可以是SCHED_FIFO
、SCHED_RR
或SCHED_OTHER
。返回值:成功时返回
0
,失败时返回错误码。
pthread_attr_setschedparam
设置线程的调度参数。
int pthread_attr_setschedparam(pthread_attr_t *attr, const struct sched_param *param);
pthread_attr_t *attr
:指向线程属性对象的指针。const struct sched_param *param
:指向调度参数的指针。返回值:成功时返回
0
,失败时返回错误码。
pthread_attr_setguardsize
设置线程的栈保护大小。
int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);
pthread_attr_t *attr
:指向线程属性对象的指针。size_t guardsize
:栈保护大小。返回值:成功时返回
0
,失败时返回错误码。
pthread_attr_setinheritsched
设置线程的调度属性继承方式。
int pthread_attr_setinheritsched(pthread_attr_t *attr, int inherit);
pthread_attr_t *attr
:指向线程属性对象的指针。int inherit
:调度属性继承方式,可以是PTHREAD_INHERIT_SCHED
或PTHREAD_EXPLICIT_SCHED
。返回值:成功时返回
0
,失败时返回错误码。
线程安全与可重入性
线程安全
线程安全是指一个函数、类或对象在多线程环境下被并发调用时,能够正确地处理多个线程之间的共享数据,不会出现数据竞争(Data Race)、数据不一致或程序崩溃等问题。换句话说,线程安全的代码在多线程环境中可以被多个线程同时调用,而不会导致意外的错误。
线程安全的代码:在多线程环境下,即使多个线程同时访问和修改共享数据,代码也能保证数据的完整性和正确性。例如,一个线程安全的计数器函数,无论多少个线程同时调用它,计数器的值始终是正确的。
线程不安全的代码:在多线程环境下,可能会出现数据竞争,导致数据被错误地修改或覆盖,从而引发程序错误。例如,一个简单的全局变量计数器,如果多个线程同时对其进行自增操作,可能会出现计数器的值不符合预期的情况。
实现方式
互斥锁(Mutex)
互斥锁是一种最常用的线程同步机制,用于保护共享数据,确保同一时间只有一个线程可以访问该数据。在 Linux C 中,可以使用 POSIX 线程库(pthread)中的互斥锁来实现线程安全。
#include <pthread.h>pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int count = 0;void* increment(void* arg) {pthread_mutex_lock(&lock);count++;pthread_mutex_unlock(&lock);return NULL;
}
在上面的代码中,pthread_mutex_lock
和 pthread_mutex_unlock
用于锁定和解锁互斥锁,确保 count++
操作是线程安全的。
信号量(Semaphore)
信号量是一种更高级的同步机制,用于控制多个线程对共享资源的访问。信号量可以用来限制同时访问共享资源的线程数量。在 Linux 中,信号量可以通过 sem_t
类型实现。
#include <semaphore.h>sem_t sem;
int count = 0;void* increment(void* arg) {sem_wait(&sem);count++;sem_post(&sem);return NULL;
}
原子操作(Atomic Operations)
原子操作是指不可分割的操作,即在执行过程中不会被其他线程中断。Linux 提供了一些原子操作的函数,例如 __sync_add_and_fetch
和 __sync_sub_and_fetch
,这些函数可以用于实现线程安全的计数器。
#include <stdatomic.h>atomic_int count = 0;void* increment(void* arg) {atomic_fetch_add(&count, 1);return NULL;
}
在上面的代码中,atomic_fetch_add
是一个原子操作函数,用于安全地对 count
进行自增操作。
线程局部存储(Thread Local Storage)
线程局部存储是一种机制,用于为每个线程分配独立的变量副本,从而避免线程之间的数据共享。在 Linux C 中,可以使用 __thread
关键字或 pthread_key_create
函数来实现线程局部存储。
__thread int count = 0;void* increment(void* arg) {count++;return NULL;
}
在上面的代码中,每个线程都有自己的 count
变量副本,因此不需要进行线程同步。
注意事项
避免全局变量:尽量减少全局变量的使用,因为全局变量是线程之间共享的,容易引发线程安全问题。
使用线程同步机制:在需要共享数据时,使用互斥锁、信号量、原子操作等线程同步机制来保护数据。
减少锁的粒度:尽量减少锁的范围,只在需要保护的代码段中使用锁,以提高程序的性能。
避免死锁:在使用多个锁时,要注意锁的顺序,避免死锁的发生。
可重入性
可重入性指的是一个函数或模块在被多次调用时,能够正确地处理多次调用之间的状态,不会因为多次调用而导致数据错误或程序崩溃。换句话说,一个可重入的函数在被多次调用时,每次调用都是独立的,不会相互干扰。
一个可重入的函数必须满足以下条件:
不依赖于全局变量或静态变量:如果函数依赖于全局变量或静态变量,那么在多次调用时可能会因为这些变量的状态不一致而导致错误。可重入函数通常使用局部变量或通过参数传递数据。
不调用不可重入的函数:如果一个函数调用了不可重入的函数,那么它本身也不可重入。例如,
strtok
函数是不可重入的,因为它依赖于全局变量。不修改输入参数:如果函数修改了输入参数,那么在多次调用时可能会导致错误。可重入函数通常不会修改输入参数,或者会通过返回值传递结果。
不使用静态或全局数据结构:如果函数使用了静态或全局数据结构,那么在多次调用时可能会导致冲突。可重入函数通常使用动态分配的数据结构或局部变量。
一个比较典型的不可重入函数例子就是 malloc 函数, malloc 函数必然是要修改静态数据的,为了保证线程安全性, malloc 函数的实现当中会存在加锁和解锁的过程,假如 malloc 执行到加锁之后,解锁之前的时候,此时有信号产生并且递送的话,线程会转向执行信号处理回调函数,假如信号处理函数当中又调用了 malloc 函数,此时就会导致死锁——这就是 malloc 的不可重入性。
不可重入函数示例:
#include <stdio.h>
#include <string.h>char* strtok_example(char* str, const char* delim) {static char* last = NULL;if (str != NULL) {last = str;}if (last == NULL) {return NULL;}char* start = last;char* end = strpbrk(last, delim);if (end != NULL) {*end = '\0';last = end + 1;} else {last = NULL;}return start;
}int main() {char str1[] = "hello,world";char str2[] = "foo,bar";printf("%s\n", strtok_example(str1, ","));printf("%s\n", strtok_example(str2, ","));printf("%s\n", strtok_example(NULL, ","));printf("%s\n", strtok_example(NULL, ","));return 0;
}
在上面的代码中,strtok_example
函数使用了静态变量 last
,因此它是不可重入的。如果在多线程环境中调用这个函数,可能会导致数据错误。