外观
Linux 多线程避坑:pthread_join 与 pthread_detach 二选一
当我们创建一个线程的时候,线程其实是有两种状态的。
一种是可连接状态,也叫 joinable,默认创建的线程就是这种状态。可连接状态线程,需要主线程调用 pthread_join 阻塞等待子线程退出,回收子线程的线程控制块信息,不然会引发资源泄露。
还有一种是分离线程状态 detached,是通过 pthread_detach 将线程设置为分离状态。分离状态的线程退出后,由内核自动回收线程控制块信息。主线程不需要调用 pthread_join 阻塞等待子线程退出。
我们分别看下这两种线程,在使用时需要会遇到哪些问题, 以及如何避免
一、可连接状态线程,pthread_join
在创建完线程后,默认的线程状态就是可连接状态 joinable。
线程创建完成后,主线程要调用 pthread_join,此时主线程会阻塞等待,直到子线程退出,完成子线程的资源回收工作。
一旦子线程退出后,pthread_join 能够获取到子线程退出的返回码。
这也是生产环境下,高并发服务器使用得最多的线程。例如多线程高并发服务器,主线程会创建网络 IO 线程以及业务线程。网络 IO 线程负责收发包、业务线程处理业务逻辑。主线程使用 pthread_join,阻塞等待网络 IO 线程和业务线程退出。
因此,大家在选型上还是使用尽量这种线程,分离状态线程 detached 很少使用。
pthread_join 本质上是给可连接线程做收尾的函数。它不是用来创建线程,也不是用来杀线程,而是用来:
- 阻塞等待某个线程结束
- 取得这个线程的退出值
- 回收这个线程结束后仍保留的资源, 也就是线程控制块
来看一个简单示例,主线程创建子线程,并等待子线程结束。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void *thread_function(void *arg)
{
printf("Sub thread started");
sleep(60);
return NULL;
}
int main(void)
{
pthread_t thread_id;
pthread_create(&thread_id, NULL, thread_function, NULL);
void * tval = NULL;
pthread_join(thread_id, &tval);
printf("sub thread exiting, ret:%d\n", (int)tval);
return 0;
}在这个示例中,主线程会在 pthread_join 调用中阻塞等待 60 s,直到子线程退出。
子线程退出的返回值是 NULL,也就是 0,因为会打印出:
sub thread exiting, ret:0
1、如果子线程退出时,没有返回值。主线程调用 pthread_join 获取到的值是多少呢?
子线程退出时的返回码,也就是子线程处理函数的返回值。
子线程退出时,函数末尾既不 return,也不调用 pthread_exit,那 pthread_join 获取到的返回值是未定义的,不能依赖它。
void *thread_function(void *arg)
{
printf("Sub thread started");
//无 return NULL;
//无 pthread_exit(NULL)
}然后主线程调用
int main()
{
......
void * tval = NULL;
pthread_join(thread_id, &tval);
}这个时候获取到的值是未定义的,有可能是 0,有可能是随机值,取决于 eax 寄存器当时的值是多少。
原因是:你的线程处理函数返回类型是:void *
也就是说,调用约定要求它返回一个指针值。线程函数正常执行到末尾时,相当于函数返回了。但你没有给返回值,此时存放返回值的 eax 寄存器里是什么内容就不确定了。
因此,线程退出时,正常的规范是要给一个返回值,而不能偷懒。
2、如果主线程不调用 pthread_join,那子线程结束后,会有什么问题?
如果主线程不调用 pthread_join,那子线程结束后,子线程会变成一个僵尸线程。类似子进程退出,父进程不 wait,子进程会变成一个僵尸进程。
子线程虽然退出了,但还有一些资源没完全释放。
每个线程都有一个线程控制块,线程退出时,线程控制块并没有释放。
线程控制块里存放了以下信息。
- 线程 ID。
- 线程是否还活着。
- 线程是否为可连接状态 joinable。
- 线程是否已经被 join。
- 线程退出码。
另外,每个线程都有一个栈空间,线程退出时栈并不一定释放,取决于 Linux/glibc 版本实现。也可能导致栈内存泄露。
如果主线程不 pthread_join,则子线程退出时,将导致线程控制块没有回收,以及栈空间未释放。最终将
- 引发内存泄露。
- 可创建线程数量减少,资源耗尽,可能无法创建出线程。
之所以线程控制块不释放,是为了让主线程 pthread_join 能够获取到线程的退出状态。如果线程退出时就将线程控制块给释放了,那主线程再也无法获取到子线程状态。
3、如果不 pthread_join,主线程退出后,子线程会是什么行为。
主线程如果是通过 return 返回,或者调用 exit 返回,则整个进程就退出了。操作系统会回收整个进程的资源,包括所有线程、内存、文件描述符等。
int main()
{
//exit(0);
//return 0;
}子线程也属于进程的一部分,进程都退出了,子线程也会立即跟着退出。
主线程如果是通过 pthread_exit 退出的,退出的只是主线程,整个进程并没有退出。此时子线程依然是会运行的
int main()
{
pthread_create(&thread_id, NULL, thread_function, NULL);
pthread_exit(NULL);
}这么写,主线程退出后,子线程还在运行。
但会有个问题,后续子线程也运行结束退出了,线程控制块也不会释放,变成僵尸线程。因为父线程已经退出了,没有人调用 pthread_join 回收线程控制块
正确的做法是:
int main()
{
pthread_create(&thread_id, NULL, thread_function, (void *)¶m);
pthread_detach(thread_id);
pthread_exit(NULL);
}此时,子线程变成分离状态(detached)。分离状态的子线程终止后自动释放资源,无需其他线程干预。由内核释放线程控制块(TCB)
因此,我们在使用上我们要注意:
- 需要等待线程结束、获取线程退出的返回值:用 pthread_join
- 不关心线程结果,让它自己跑完自己释放:用 pthread_detach
根据需要两者 2 选 1,不要什么都不用
二、分离状态 detached
把一个线程设置为“分离状态”,使该线程结束后,由系统自动回收其线程资源,不再需要、也不允许其他线程调用 pthread_join() 回收它。它解决的是“线程退出后的资源回收方式”
分离线程,主要用于一些不关注线程退出状态的场景。
- 后台任务(写日志记录、监控、定时任务)
- 主线程不需要获取线程返回值的场景
在生产环境下,使用场景是非常少的。但既然 Linux 操作系统提供了这么个功能,还是要了解下,增加些知识面。
有两种方式将线程设置为分离状态。
- 创建子线程的时候,通过设置线程属性,调用 pthread_attr_setdetachstate 将线程设置为分离状态。这是最推荐的做法。
- 子线程运行后,通过 pthread_detach 将子线程设置为分离状态。
1、创建子线程的时候,通过 pthread_attr_setdetachstate 将线程设置为分离状态
在创建子线程的设置,可以通过设置线程属性,将线程状态设置为分离状态。
子线程一运行起来,就变成分离状态。
Linux 操作系统提供了两个接口,用于设置、获取线程分离状态。
| API 接口 | 功能 |
|---|---|
| pthread_attr_setdetachstate | 将线程设置为分离状态,或者可连接状态 |
| pthread_attr_getdetachstate | 获取线程的状态 |
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <errno.h>
#include <string.h>
void *thread_func(void *arg)
{
sleep(1);
printf("sub thread running\n");
return NULL;
}
int main(void)
{
pthread_t thread_id;
pthread_attr_t attr;
int ret;
// 设置线程属性,将线程状态设置为分离
ret = pthread_attr_init(&attr);
ret = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
// 获取是否已经为分离状态
int detachstate;
ret = pthread_attr_getdetachstate(&attr, &detachstate);
if (detachstate == PTHREAD_CREATE_DETACHED)
{
printf("sub thread state: DETACHED\n");
}
ret = pthread_create(&thread_id, &attr, thread_func, NULL);
pthread_attr_destroy(&attr);
// 此时获取线程退出结果,必然失败
ret = pthread_join(thread_id, NULL);
if (ret != 0)
{
printf("pthread_join failed: %s, ret:%d\n", strerror(ret), ret);
}
//主线程运行时间必须比子线程长,不然进程退出,子线程也退出了
sleep(10);
return 0;
}对于分离线程,主线程是无法获取分离线程的返回值。返回值被存储在 TCB(Thread Control Block)中,分离线程退出时,由内核释放 TCB,主线程无法获取到线程的退出状态。
因此,一旦子线程变为分离状态,主线程就不能调用 pthread_join(),否则返回 EINVAL。
另外,即使是分离状态,如果主线程使用的是 return,或者 exit 退出,则主线程退出意味着整个进程退出了。此时分离状态的子线程也会退出。
因此分离线程正确使用方式是:
- 主线程生命周期长于分离线程
- 主线程退出时,使用 pthread_exit,而不是 return,或者 exit
2、子线程运行后,通过 pthread_detach 将子线程设置为分离状态。
子线程运行后,主线程调用 pthread_detach 将子线程设置为分离状态。只是将子线程设置为分离状态,不会阻塞等待子线程结束。pthread_detach 调用后立即就会返回。
来看一个示例,在这个示例中,主线程创建完线程后,设置子线程为分离状态。子线程退出后,内核会自动回收线程的控制块信息。
#include <stdio.h>
#include <string.h>
static void *worker(void *arg)
{
sleep(60);
printf("worker running\n");
return NULL;
}
int main(void)
{
pthread_t tid;
int ret = pthread_create(&tid, NULL, worker, NULL);
//线程创建完成后,设置为分离状态
pthread_detach(tid);
// join已经分离的线程会失败,
int ret = pthread_join(tid, NULL);
if (ret != 0)
{
printf("pthread_join failed: %s, ret:%d\n", strerror(ret), ret);
}
//不能用return和exit,会导致整个进程退出。
//分离的线程也会被退出
pthread_exit(NULL);
}这个示例中,哪怕父线程退出了,子线程也会继续运行,直到 60 s 后退出。子线程退出时,由内核自动回收线程控制块。
主线程调用,还是子线程调用 pthread_detach
主线程创建子线程,要将子线程设置为分离状态。是主线程调用,还是子线程自己调用 pthread_detach?
两种方式都可以。pthread_detach() 不要求必须由主线程或目标线程自己调用;任何线程都可以把一个有效的可连接线程 joinable 设置为分离线程状态。
工程上通常推荐:由创建者决定并设置线程的回收方式。因此由主线程调用:最常见。
如果子线程自己将自己设置为分离状态,和主线程的 join 存在并发竞争。
我们看下这个过程:
1、子线程可以通过 pthread_self() 获取自己的线程 ID,然后分离自己
void *worker(void *arg)
{
int ret = pthread_detach(pthread_self());
return NULL;
}2、在子线程执行 pthread_detach(pthread_self()) 之前,主线程可能不知道它是否已经成功 detach。如果主线程同时尝试 join,就会出现竞态:
主线程:pthread_join(tid, ...)
子线程:pthread_detach(pthread_self())
谁先成功不可预测,这属于错误的线程生命周期设计
分离状态的线程,是否还能被取消?
答案是: 可以。
分离线程只影响资源回收,内核自动释放线程控制块。并不影响 pthread_cancel():
因此,对于分离线程,也是可以调用 pthread_cancel(tid); 将分离线程取消,让分离线程退出的。
这和可连接线程没有任何区别。
但这里存在生命周期问题:
- 分离状态的线程可能已经退出;
- 退出后 pthread_t 对应的线程标识可能失效;甚至可能被重新用于另一个新线程。
- 此时主线程取消分离线程,行为是未定义的。
因此,最好还是使用 joinable 可连接线程更稳妥。
三、写在最后
UNIX 环境高级编程 这本书介绍了可连接和分离线程两种状态。但似乎有点教材了,比较抽象,对初学者而言没那么容易理解,不知道怎么用。
文章对线程两种状态,在使用过程中会遇到的各种问题进行了展开分析。读者在回头看 UNIX 环境高级编程这本书时,也就不那么抽象了。
本章所有的示例代码,都可以从这里下载:
https://gitee.com/apelifexu/linux-network/tree/master/thread
位于 pthread_join 和 thread_detach 两个目录
我尽量站在读者的角度把来龙去脉讲清楚了,但每个人理解不一样。有任何疑问,可以在评论区里讨论,也可以联系作者。
如何联系作者
yudongtc 这个博客每周更新一篇高并发干货,图解方式讲解,需要可以浏览器访问。
关注高并发成长记,加入 linux 网络编程交流群,一起讨论。