ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Linux线程分离状态详解:原理、API与实战

Linux线程分离状态详解:原理、API与实战 前阵子帮人定位一个线上服务的内存问题现象非常典型RSS一直往上涨重启就好了过一阵又涨上去。查了半天堆内存没发现泄漏点后来一看/proc里的 Threads 数量才发现问题根本不在堆上而是线程退出后一直没人接管。代码里每个连接都pthread_create了一个线程处理完就return却既没有pthread_join也没有做任何分离设置。这类问题在 Linux 服务端开发里非常常见核心就是线程属性里的detachstate——分离状态。今天围绕 Linux 线程属性设置分离技术把PTHREAD_CREATE_DETACHED、pthread_attr_setdetachstate、pthread_detach这几个概念一次讲透覆盖原理、实操、踩坑和选型。刚接触 pthreads 的 C 开发者可以照着做准备 Linux 面试的同学也能从中捞到几个常考细节。1. 一个“内存只涨不跌”事故引出的问题分离状态究竟管什么1.1 事故现场还原先把这个事故的场景还原一下。那是一个典型的 TCP 长连接服务主线程accept之后每来一个连接就pthread_create出一个线程处理。代码简化下来大概长这样void *handle_conn(void *arg) { int fd *(int *)arg; // 处理请求... close(fd); return NULL; } int main(void) { // accept循环 while (1) { int fd accept(listen_fd, ...); pthread_t tid; pthread_create(tid, NULL, handle_conn, fd); // 没有join也没有detach } }用ps -eLf一看线程数量蹭蹭往上涨压测一停数量也不回落。刚开始同事怀疑是业务代码里堆内存泄漏排查一圈没找到明显问题。真正的原因非常“基础”每个线程退出之后它占用的资源压根没有回收机制去清理。为什么会这样因为pthread_create创建的线程默认状态是PTHREAD_CREATE_JOINABLE也就是“可 join”。一个可 join 的线程退出后并不会自动把所有资源还回去而是把退出状态、线程栈、线程描述符、线程局部存储TLS都保留在内存里等待另一个线程调用pthread_join来“收尸”。没人调pthread_join这些资源就一直在数量一多内存自然只涨不跌线程数也只涨不跌。1.2 joinable线程的资源保留机制这里要稍微深入一点说说 joinable 线程退出后到底保留了什么东西。从 NPTLNative POSIX Thread Library的实现来看线程退出时运行库并不会立刻释放它的线程描述符struct pthread也不会立刻把栈归还。它会把退出的线程放进一个内部管理链表里等pthread_join来取退出状态。pthread_join拿到返回值之后运行库才真正开始释放栈、清理描述符、回收 TLS。整个流程和进程的 zombie 机制有点像但又不完全等同一个进程退出后变成僵尸是为了让父进程通过wait拿到退出码一个 joinable 线程退出后保留资源是为了让任意线程通过pthread_join拿到返回值。可以打个比方可 join 线程就像在办事大厅里处理完业务的窗口人已经走了但工位还占着后台系统不释放这个工位必须等办完业务的人回来后把号牌交回工作人员确认结账工位才能腾给别人。如果缴费处一直没人来对接工位就一直空占着。理解了这一点再看那起事故就非常清晰每个连接线程都处理完了业务return 之后工位全占着系统运行得越久空占的工位越多最后把内存耗尽。1.3 分离状态改变了什么分离状态要解决的就是这个问题。PTHREAD_CREATE_DETACHED一旦在创建线程时被设置进去线程就变成了“分离线程”。分离线程退出的那一刻运行库直接自动回收它的栈、线程描述符和 TLS不需要也不允许再调用pthread_join。如果还用办事大厅的比喻分离线程就像自助退房房卡一插系统立刻结算房间马上释放全程不需要人工过来交接。这里有两个关键点需要时刻记住。第一分离状态不影响线程的运行逻辑。线程该执行什么代码、执行多久、怎么同步都跟分离不分离没关系。分离只是改变了线程退出之后的“善后机制”是自动清理还是等待 join。第二分离线程不能 join也不应该去 join。一旦状态是分离pthread_join拿不到任何有意义的结果只会得到错误码。2. 线程属性对象的正确打开方式初始化、配置、销毁全流程2.1 pthread_attr_t承载创建参数的结构体要设置分离状态绕不开pthread_attr_t这个线程属性对象。它本质上是一组创建参数的集合pthread_create创建线程时把这份参数读过去决定新线程的栈大小、栈地址、分离状态、调度策略等。pthread_attr_t里最常见几个字段detachstate分离状态取值PTHREAD_CREATE_DETACHED或PTHREAD_CREATE_JOINABLEstacksize线程栈大小stackaddr线程栈的起始地址一般不用手动设guardsize栈溢出保护页大小schedpolicy和schedparam调度策略与调度参数inheritsched调度属性是继承还是显式设置scope竞争范围这篇文章只深入讲detachstate但必须提醒一句pthread_attr_t在使用前一定要先初始化。很多新手直接定义了一个pthread_attr_t attr;就开始设置字段这种用法在某些实现下碰巧能工作但绝对不是 POSIX 保证的行为也不该这么写。2.2 init与destroy必须成对出现标准用法是先用pthread_attr_init初始化配置完需要的字段之后再传给pthread_create。线程创建完成后用pthread_attr_destroy释放这个属性对象。最标准的模板长这样pthread_attr_t attr; pthread_t tid; int rc pthread_attr_init(attr); if (rc ! 0) { fprintf(stderr, pthread_attr_init failed: %s\n, strerror(rc)); return 1; } rc pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); if (rc ! 0) { fprintf(stderr, pthread_attr_setdetachstate failed: %s\n, strerror(rc)); pthread_attr_destroy(attr); return 1; } rc pthread_create(tid, attr, worker, arg); if (rc ! 0) { fprintf(stderr, pthread_create failed: %s\n, strerror(rc)); pthread_attr_destroy(attr); return 1; } pthread_attr_destroy(attr);这里有几个容易被忽略的细节。第一pthread_attr_init在 glibc 里做的事情主要是给属性赋默认值但你不能因为“只是赋默认值”就跳过它。有的平台内部会分配额外的资源不初始化直接使用属于未定义行为。第二pthread_attr_destroy只销毁属性对象本身不影响已经创建出来的线程。线程在pthread_create的时候已经把需要的参数拷贝走了属性对象后续是销毁还是复用跟那个线程已经没有关系。所以pthread_create返回之后马上pthread_attr_destroy一点问题都没有。第三如果你要创建大量同属性的线程完全可以把同一个 attr 反复使用只需要初始化一次、循环创建、最后销毁一次即可。但要注意attr 里配置的字段在你的循环期间不应被别的线程修改否则有数据竞争。2.3 setdetachstate与getdetachstate的边界检查我再把两个 API 的签名贴出来列清楚边界条件int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate); int pthread_attr_getdetachstate(const pthread_attr_t *attr, int *detachstate);pthread_attr_setdetachstate的detachstate只接受两个值PTHREAD_CREATE_DETACHED和PTHREAD_CREATE_JOINABLE。传其他任何值函数返回EINVAL。有个很常见的坏味道是直接传数字pthread_attr_setdetachstate(attr, 1); // 1恰好是DETACHED在 glibc 上1确实是PTHREAD_CREATE_DETACHED甚至不少教材都这么写但我不建议这么做。原因很简单代码的可读性和可移植性。arm-linux-gnueabihf等嵌入式交叉工具链虽然也多沿用 glibc 宏值但不是所有 libc 实现都保证这个数字。正确写法是永远用宏。配套的pthread_attr_getdetachstate用来把当前配置读回来常用于调试和验证。比如你想确认一个 attr 到底被设成了什么状态可以这样int detachstate -1; if (pthread_attr_getdetachstate(attr, detachstate) ! 0) { // 错误处理 } if (detachstate PTHREAD_CREATE_DETACHED) { // 已经设置为分离 }再说一个 pthead 系列 API 的通用坑这些函数出错时返回的是错误码而不是设置全局errno。如果你习惯性地用perror打印打出来的是上一次系统调用的错误完全对不上。要像我上面代码那样用strerror(rc)配合返回值打印。3. 从pthread_create到线程退出的完整生命周期分离标志在哪一步生效3.1 pthread_create收到attr之后的处理路径很多人以为detachstate在线程创建后被保存成一个“状态变量”运行过程中随时可以改变。这是误解。分离标志真正生效的窗口非常短就在pthread_create这一次调用内部。pthread_create拿到 attr 后会读取其中的detachstate字段把它作为新线程创建的初始属性。从 NPTL 的实现上看分离线程的底层创建参数就跟 joinable 线程不一样clone系统调用层面会有对应的标志位区分分离线程通常走CLONE_DETACHED这样的路径。我特意用“通常”这个词是因为 glibc 的底层实现迭代过很多版具体到某个版本细节可能有差异但对用户态开发者来说你只需要理解一个稳定的结论分离状态在pthread_create成功返回的那一刻就已经确定。之后你再怎么改 attr对已经创建出来的线程也毫无影响。所以正确的代码顺序永远是“先配置 attr再创建线程”。不要把pthread_attr_setdetachstate放在pthread_create之后调用那是纯粹的无效代码还容易让人误以为线程已经是分离状态了。下面这段代码就是一个标准的创建流程pthread_attr_t attr; pthread_t tids[5]; pthread_attr_init(attr); pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); for (long i 0; i 5; i) { int rc pthread_create(tids[i], attr, worker, (void *)i); if (rc ! 0) { fprintf(stderr, create thread %ld failed: %s\n, i, strerror(rc)); } } pthread_attr_destroy(attr); // 主线程不return而是让出让分离线程跑一会儿 pthread_exit(NULL);注意这里的pthread_exit(NULL)后面会专门讲为什么。3.2 线程退出时内核与C库分别做了什么当分离线程执行完return或者调用pthread_exit退出时用户态运行库和内核会各做各的清理。内核层面负责清理的是线程内核栈、task_struct等内核资源。用户态运行库负责的是线程栈、线程描述符、TLS 等进程内存空间内的资源。两者的配合由底层 clone 参数决定。对于 joinable 线程退出时运行库不能直接把栈和描述符释放掉因为其他地方可能还要通过pthread_join读取退出状态。于是运行库把线程描述符挂在“已退出待 join”的链表上线程栈也保留等pthread_join到来之后再统一释放。对于 detached 线程运行库在线程退出路径上直接把这些资源回收。回收之后这个线程 ID 理论上就不应再被使用再用pthread_join去join这个 ID要么找不到ESRCH要么发现不是 joinable 线程EINVAL。真实工作中还有一个相关现象即使所有线程都正常退出了glibc 也可能把一部分线程栈留在缓存里以便下次创建线程时复用这是性能优化不是泄漏。但如果你连续创建大量 joinable 线程并且都不 join那缓存里的描述符不会释放加上没被 join 的线程保留的资源内存上涨速度会非常明显不是一句“线程缓存”就能解释过去的。3.3 分离线程不等于立即销毁线程这是最容易误解的一个点我在面试时也经常拿这个考察候选人。“分离”听上去像“把它扔掉”但对线程本身来说分离状态不会导致它被终止、被取消、被冻结。一个分离线程被创建后依然正常调度、正常执行能用栈、能访问堆、能加锁解锁甚至连pthread_cancel都能照常生效。分离只决定了一件事它退出时资源是自动回收还是等待 join。打个比方分离状态并不是给线程判了死刑而是给线程的“遗产处理方式”做了个约定你走了东西自动被收走不需要有人来办继承手续。想清楚这一点很多工程设计决策会变得简单。比如有人问“我怎么让一个线程处理完任务后立刻消失”分离状态解决不了这个问题。线程执行完函数自然就退出了这跟你有没有设置分离没有关系。如果线程被卡在某个阻塞调用里分离状态也不会帮它挣脱你依然要设计退出标志、条件变量、管道唤醒等手段。4. 高频踩坑实录detach相关问题的完整排查链路4.1 排查链路一创建后再用pthread_detach是否等价于创建时分离我在团队代码里经常看到这种写法pthread_t tid; pthread_create(tid, NULL, worker, NULL); pthread_detach(tid);有人会问这跟创建时用 attr 设置分离效果一样吗大部分情况下效果等价最终都是把线程变成 detached。但需要注意两点区别。第一中间存在一个竞态窗口。pthread_create返回后、pthread_detach执行前如果另一个线程抢先调用了pthread_join那么这个线程就会作为 joinable 线程被回收后面再pthread_detach就会得到ESRCH或EINVAL。虽然这个窗口在代码里很小但线上并发场景下一切皆有可能。第二如果线程已经退出你才想起要pthread_detach这仍然能成功运行库会把刚才保留的资源立刻释放掉。反过来如果线程已经参加过 join那它的 ID 就已经失效pthread_detach会报错。也就是说pthread_detach既可以对“正在运行的线程”生效也可以对“已经退出但还没 join 的线程”生效。我的建议很简单能创建时分离就尽量不要事后 detach。创建时分离相当于把意图固化在线程出生的那一刻没有竞态窗口代码也更好 review。pthread_detach更适合那种“一开始不知道要不要分离运行到某个条件后才决定分离”的场景说实话这种场景很少。4.2 排查链路二对已分离线程调用pthread_join会得到什么排查线上问题的时候经常有人对着代码说“这个线程创建了怎么不 join加个pthread_join吧。”结果一加就加出来一个EINVAL或者ESRCH。来看这段问题代码pthread_t tid; pthread_attr_t attr; pthread_attr_init(attr); pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); pthread_create(tid, attr, worker, NULL); void *retval; int rc pthread_join(tid, retval); // 这里会报错pthread_join对一个分离线程会返回什么POSIX 规定如果线程不是可 join 线程返回EINVAL如果找不到对应线程 ID返回ESRCH。实际行为取决于你调用时线程处于什么阶段如果线程还在跑或者刚退出但描述符还在可能返回EINVAL如果描述符已被回收复用则可能返回ESRCH。排查这种问题的链路我一般是这样走的第一步确认这个线程是怎么创建的attr 里有没有设置过PTHREAD_CREATE_DETACHED。不要凭记忆最好在代码里打印pthread_attr_getdetachstate的结果验证。第二步确认有没有别的地方对这个线程调用过pthread_detach。同一个线程 ID 被pthread_detach两次第二次也会报EINVAL。第三步确认线程是否已经退出并被 join 过。一个线程被 join 之后它的 ID 就不再有效后续任何pthread_join或pthread_detach都是无意义的。第四步把返回错误码打全用strerror转成可读信息不要只打印一个数字。4.3 排查链路三setdetachstate传参错误导致的EINVAL另一种常见问题是pthread_attr_setdetachstate本身就返回EINVAL。通常有两种原因。一种是传了非法值比如传2、传-1或者从某个配置文件里读出来一个没做校验的数字。解决办法是校验后转成宏再传。另一种是没有初始化 attr 就直接设置。有些开发者图省事跳过pthread_attr_initpthread_attr_t attr; pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED);这在 glibc 下有时会“碰巧成功”因为 glibc 的 attr 内部有 magic 字段可能误打误撞通过了校验但换到其他实现或者换到栈上内存被污染的场景就会随机失败。千万不要依赖“碰巧能工作”。正确的排查顺序永远是init-set-create-destroy一步都不能省。4.4 排查链路四主线程return与pthread_exit的连带影响再来一个特别隐蔽的连带坑主线程是return还是pthread_exit直接决定你这个进程还能不能继续跑。很多新手以为主线程return只是结束主线程其他线程继续跑。实际上 C 语言里 main 函数return等价于调用exitexit会终止整个进程所有线程——不管是分离的还是未分离的——全部被干掉。所以当你创建了一堆分离线程主线程里又没有任何pthread_join可等的时候你必须在 main 的最后调用pthread_exit让主线程退出但不触发进程退出进程会一直存活到所有非主线程都结束为止。我在前面的示例代码里特意写了pthread_exit(NULL)就是出于这个原因。如果你不写它那些分离线程可能刚被创建出来还没来得及执行进程就结束了你观察不到任何线程运行的效果。5. 分离还是joinable不同场景下的选型决策与工程实践5.1 优先使用分离状态的三类场景经验上下面这几类线程适合从一开始就设置成分离。第一类一次性任务不需要回传结果。比如日志异步落盘、统计上报、异步通知、缓存预热这类任务执行完就结束返回值没有人关心分离是最合适的。第二类生命周期很长的常驻线程。比如监控线程、心跳线程、资源巡检线程它们一般是进程启动时拉起来进程退出时生命周期自然结束中间根本没有“join 拿返回值”的需求分离也没问题。第三类高频短连接处理线程。回到文章开头那个事故场景每来一个连接创建一个线程处理完就退出返回值没人要这种情况就应该在创建时设置分离。一个通用判断标准是如果你写代码时发现自己创建线程的目的是“让它自己去干活我不想知道也不想知道它什么时候结束”那就用分离。5.2 必须保持joinable的三类场景有分离的适用场景就一定有必须保持 joinable 的场景。下面三种我特别提醒你注意。第一业务需要获取线程的返回值或退出状态。比如工作线程算出一个结果主线程要拿这个结果继续处理。这种情况下一定不能分离。第二优雅停机时需要对工作线程做收尾。服务收到退出信号后通常要等所有工作线程把手头的事处理完再退出这就需要记录每个线程 ID在停机流程里统一pthread_join。分离线程无法 join你会陷入“不知道它到底退没退出”的尴尬局面。第三线程的数量和生命周期需要被明确管理。比如线程池池管理器需要知道每个 worker 当前是空闲还是忙碌、上次退出是什么原因这些都是 joinable 线程天然能提供的信息。我简单列个表方便你对照选择维度joinable线程detached线程资源回收时机调用pthread_join后回收线程退出时自动回收获取返回值通过pthread_join的retval参数不行需要自己用共享变量传递确认线程结束可以join等待需要自己设计条件变量/原子标志创建方式默认就是joinableattr设置detachstate或pthread_detach适用场景需要结果、需要收尾、需要受控一次性任务、常驻任务、无结果需求这个表做项目选型时可以直接贴在文档里用。5.3 线程池与混合模型中的常见做法补充一下线程池的做法。线程池里的 worker 线程通常保持 joinable而不是分离。原因很实际线程池需要统一管理 worker 的生命周期。池里的线程一般运行时间很长反复从任务队列里取任务执行它们不会轻易退出。真要退出的时候要么是线程池被销毁要么是 worker 出现异常这两种情况下池管理器都需要通过 join 来确认线程真正结束进而统计、清理、甚至重新拉起一个 worker 补位。如果非要把线程池的 worker 设置成分离你会遇到一个问题无法可靠地确认“某个 worker 是否还活着”。因为分离线程退出后资源被自动回收你拿不到它的退出通知。你只能靠原子计数器或者条件变量模拟 join 的语义代码复杂度会明显上升。所以一般情况下线程池的 worker 保持 joinable由池管理器统一 join是更稳妥的设计。6. 几个常考细节与实用定位工具6.1 容易被问倒的几个点把面试和日常 review 中容易踩的点集中列一下。第一题如何创建一个分离线程两种方式创建前用 attr 设置PTHREAD_CREATE_DETACHED或者创建后调用pthread_detach。推荐前者。第二题joinable 线程退出后不 join 会怎样线程的资源不会自动释放包括退出状态、栈、线程描述符和 TLS长时间累积会造成内存和 fd 泄漏现象是进程内存只涨不跌、线程数只增不减。第三题pthread_join对分离线程返回什么返回EINVAL或ESRCH取决于线程 ID 是否还能被找到。不要把这个返回值写死在代码判断里。第四题分离线程能被pthread_cancel取消吗能。分离只影响资源回收不影响线程的取消机制。第五题分离线程的退出状态怎么传递不能通过pthread_join返回值必须在线程内部把结果写入全局变量或堆内存配合条件变量或原子变量通知其他线程。这几个问题平时看着简单真到线上排查和面试答辩时能把返回值、竞态窗口和资源回收机制说清楚的人并不多。6.2 实践中用于定位线程问题的工具清单排查线程相关问题时我会按下面的清单一步步来。ps -eLf快速观察进程下的线程数量判断线程是否异常暴涨。top -H -p PID查看单个进程内每个线程的 CPU 占用定位忙等或死循环线程。/proc/PID/task/目录统计目录下的条目数就可以得到实时线程数。gdb用info threads列出所有线程可以对指定线程加断点查看线程栈。valgrind --toolhelgrind检测线程间的数据竞争和锁序问题适合排查偶发的诡异崩溃。编译时记得加-pthread不要只在链接时手动加-lpthread-pthread还负责定义必要的宏和编译选项。实际排查询问线程泄漏类问题最快的动作是先ps -eLf确认线程数再用cat /proc/PID/status | grep Threads确认增长趋势。如果线程数稳定但内存上涨那就看 joinable 线程是不是没人接管。如果线程数和内存都在涨优先查分离设置有没有配错。最后再分享一点实践习惯我现在的默认习惯是凡是“创建出去就不打算再 join”的线程一律在 attr 里就把detachstate设为PTHREAD_CREATE_DETACHED并且封装一个带名字参数的 wrapper比如thread_create_detached()。这样代码 review 时一眼就能看出“这里是有意不 join”。维护过老代码之后你会发现团队协作里最怕的不是分离线程本身而是那种“创建了线程但没人说得清它到底要不要 join”的暧昧状态。把意图写进代码里比任何技巧都重要。
返回列表