0%
29.3 用 `kill` 发送信号给特定线程的尝试
线程号在整个系统是唯一的,而 kill
命令也疑似可以精准发送信号给目标线程(见下方代码的测试 1;测试时只保留测试 1 和 2 其中一个,注释掉另外一个)。特殊情况:
- 当目标线程已经是僵尸的时候,则会将信号发给同组的其他线程。见测试 2。
- 如果线程正常退出,也被系统正常回收了资源(不是僵尸),那么 kill 就会报错(因为没有这个线程了,也不可能找到它的线程组),如
bash: kill: (23196) - No such process
。在很极端的情况下,这个 PID 被其他进程使用了,kill 会将信号发给不相关的进程。但是由于 Linux 以循环递增的方式重用 PID,这需要相当长的时间,一般不会遇到这种情况。
#define _GNU_SOURCE
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
// Not portable since gettid is only on Linux.
unsigned long get_thread_id(void) { return (unsigned long)gettid(); }
void *f(void *arg) {
fprintf(stderr, "Thread %lu starts\n", get_thread_id());
for (;;) {
pause();
fprintf(stderr, "Thread %lu is awoken\n", get_thread_id());
pthread_exit(NULL);
}
fprintf(stderr, "Thread %lu but shouldn't reached here\n", get_thread_id());
return NULL;
}
void chld_handler(int sig) {
// (f)printf is not async-signal-safe, but I use it for testing.
fprintf(stderr, "Handler thread is %lu\n", get_thread_id());
}
int main() {
fprintf(stderr, "Main thread is %lu\n", get_thread_id());
// 因为 SIGCHLD 的默认行为是忽略,所以不设置信号处理器函数,SIGCHLD 就不会让 thread1 从 pause() 返回。
struct sigaction action;
action.sa_handler = chld_handler;
sigset_t signals_blocked;
sigfillset(&signals_blocked);
action.sa_mask = signals_blocked;
action.sa_flags = 0;
if (sigaction(SIGCHLD, &action, NULL) != 0) {
perror("sigaction");
return 1;
}
pthread_t thread1;
pthread_create(&thread1, NULL, f, NULL);
// 测试 1:让主线程去等待 thread1,通过 htop 可以发现两个线程都活着。以两个线程之一的 PID 为参数发送 SIGCHLD
// 信号,会使得对应的线程处理这个信号。
pthread_join(thread1, NULL);
// 测试 2:让主线程先退出,只有 thread1 活着,这个时候无论是根据两个线程中的哪一个发送信号,都是由活着的 thread1
// 处理。但为什么主线程退出后会成为僵尸?
// pthread_detach(thread1);
// pthread_exit(NULL); // 主线程结束后不会再处理信号
}
主线程为什么退出后成为僵尸?为什么我不能成功对主线程 join 或者 detach 以在其终止后回收资源(不再是僵尸)?
32 线程取消
API
#include <pthread.h>
int pthread_cancel(pthread_t thread);
线程取消状态和线程取消类型
它们分别可以用 pthread_setcancelstate
和 pthread_setcanceltype
来设置。
#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);
调用 fork()
或者 exec()
时线程的行为
某线程调用 fork()
时,子进程会继承当前线程的取消类型和状态。某线程调用 exec()
时,新程序主线程的取消类型和取消状态都会被重置。解释:
多线程程序调用 fork()
之后,新进程中只会留下一个线程,大概这个新线程的有些属性会从 fork()
的调用者线程继承。根据 man 手册:
33 线程的更多细节
主要内容
- 线程和传统 UNIX API 之间的交互(信号、进程控制原语)
- Linux 上的两个线程实现
线程栈的大小
在 x86_64-linux-gnu 上,除了主线程,其他线程的缺省大小都是 2MB。
Tip
2025/3/8 省流:
- 缺省:主线程一般 8MB(
ulimit -s
看,单位是 KB 不是 B),非主线程缺省 2MB。 - 最小:16KB。
参考:Go 中协程最小栈大小是 2KB。
// https://go.dev/src/runtime/stack.go#L74
// The minimum size of stack used by Go code
stackMin = 2048
设置线程栈的大小
为了设置线程栈的大小,需要在创建线程前构建一个线程属性(pthread_attr_t
),然后将其作为 pthread_create
的参数。
- 可以使用
pthread_attr_setstacksize()
设置线程栈的大小。 - 也可以用
pthread_attr_setstack()
同时设置线程栈的大小和地址,但是这样做会损害可移植性。
33.1 能不能用 kill(1) 给特定线程发送信号呢?
用 kill
发送信号给特定线程的尝试 这篇文章提到了用 kill(1) 给特定的线程发送信号,但是标准上没有说这样是可行的。因此这篇文章探讨一下为什么 kill(1) 会具有这样的行为。以下阅读的代码都是在 2024 年 7 月 12 日 的主分支上的最新代码。
kill(1) 的实现
这里 是 kill
命令实现的关键部分,其中只调用了 kill()
系统调用。所以可以排除是 kill(1) 自己处理了发送信号给特定线程的过程。
kill(2) 在 glibc 中没有实现
kill 系统调用是需要链接 libc 的,其声明的头文件也是在 signal.h 中,因此我们需要先看 glibc 的源码。
在 glibc 源码中没有找到 kill()
对 Linux 系统调用的包装。只有一个 __kill()
的空实现(总会设置 errno
为 ENOSYS
并返回 -1)并将其声明为 kill
的弱别名(使用 weak_alias
宏)。(对于某些架构,比如 hurd,会提供 kill()
的实现,可能是这些系统上没有 kill 系统调用)。因此,kill 系统调用是直接在 Linux 内核中实现的。
Linux kernel 中对 kill(2) 的实现
tkill()
和 tgkill()
首先看下 tkill()
和 tgkill()
的实现,方便和 kill()
做下对比。在 Linux 源码中,kernel/signal.c 中有 tkill()
和 tgkill()
的定义,它们都调用了 do_tkill()
。
31 线程特有数据(TSD)和线程局部存储(TLS)
TSD 和 TLS 都属于每线程存储(Per-Thread Storage)。
仅初始化一次
#include <pthread.h>
pthread_once_t once_control = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control, void (*init_routine) (void));
线程特有数据(Thread-Specific Data)
在 C11 之前,thread_local
变量是不受到语言支持的,因此为了创建线程特有数据就只能用相关的 API。
Pthreads 系列的特有数据 API 看起来很难用:
#include <pthread.h>
int pthread_key_create(pthread_key_t *key, void (*destr_function) (void *));
int pthread_key_delete(pthread_key_t key);
int pthread_setspecific(pthread_key_t key, const void *pointer);
void * pthread_getspecific(pthread_key_t key);
30 线程同步
互斥量
互斥量 API
为了在 man 手册中看到这些内容,Debian 系统应该安装 glibc-doc 包。
#include <pthread.h>
pthread_mutex_t fastmutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t recmutex = PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP;
pthread_mutex_t errchkmutex = PTHREAD_ERRORCHECK_MUTEX_INITIALIZER_NP;
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
应该使用 PTHREAD_MUTEX_INITIALIZER
、PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP
、PTHREAD_ERRORCHECK_MUTEX_INITIALIZER_NP
之一,或者 pthread_mutex_init()
来初始化一个互斥量。只有 PTHREAD_MUTEX_INITIALIZER
是标准的,另外两个都是 Linux 提供的非标准的宏。
虽然书上说前者是用来初始化静态分配的互斥量的,但是没有实现禁止用其初始化在堆上分配的互斥量,也无法确定“静态分配”指的是有稳定地址还是一定要分配在静态数据存储区。另外,前面几个宏只能使用默认的属性,想要对属性进行更加精细的控制,则需要使用
pthread_mutex_init()
。
初始化一个已经初始化过的互斥量是 UB。
29.1 线程共享了什么、私有数据是什么?
线程共享了什么?
实际上就是
clone()
用于支持线程的标志 中的那些标志提到的内容。以下抄书:
除了全局内存之外,线程还共享了一些其他属性(这些属性对于进程而言是全局性的, 而并非针对某个特定线程),包括以下内容。
- 进程 ID(process ID)和父进程 ID。
- 进程组 ID 与会话 ID(session ID)。
- 控制终端。
- 进程凭证(process credential)(用户 ID 和组 ID )。
- 打开的文件描述符。
- 由
fcntl()
创建的记录锁(record lock)。 - 信号(signal)处置。
- 文件系统的相关信息:文件权限掩码(umask)、当前工作目录和根目录。
- 间隔定时器(
setitimer()
)和 POSIX 定时器(timer_create()
)。 - 系统 V(system V)信号量撤销(undo,semadj)值(47.8 节)。
- 资源限制(resource limit)。
- CPU 时间消耗(由
times()
返回)。 - 资源消耗(由
getrusage()
返回)。 - nice 值(由
setpriority()
和nice()
设置)。
2025/3/8 总结一下,希望好记一点:
- 内存:共享虚拟地址空间。
- 一组标识:pid/ppid/pgid/sid、进程凭证(uid 和 gid)。
- 文件:文件描述符、文件记录锁、cwd、umask
- 进程交互:sysv 信号量、控制终端(算是进程交互吧?因为有控制终端代表着有其他进程来控制)
- 信号和时间:信号处理方式、定时器
- 资源分配和调度:资源限制 rlimit、CPU 时间 / 资源消耗的统计信息、nice 值
从另外一个角度考虑,线程共享了什么可以看
clone()
用于支持线程的标志。
线程的私有数据是什么?
书中列举了一部分:
29.2 线程创建
pthread_t
类型
在 Linux 中是个整数(unsigned long
),NPTL 将其强制转换成指针。将其解释为整数或指针是不可移植的,在其他平台上,此类型可能是某个结构体。
Pthreads 线程的终止如何影响进程的终止?
在 Linux 中,要让一个进程终止,需要让它的所有(包括 detach 的)线程都终止。怎么理解包括 detach 的线程也要终止呢?如果一个线程被 detach,但是它还在运行过程中,使用了进程的资源,因此进程就不能终止。这和 C++ 的 std::thread
API 的 detach 含义有点不同,后文会解释。
使用 exit()
退出程序就会终止所有的线程(从 main()
返回则 C 语言运行时会调用 exit()
)。这其实给进程保持运行增加了一个隐含的条件:主线程不能从 main()
函数中返回。Pthreads API 的确有绕开这一条件的方法,用 pthread_exit(NULL)
来退出主线程,则进程不会直接退出,而是会等待其他线程都结束、或任意线程调用 exit()
。
Note
注意返回和退出的区别。
Pthreads API 提供了 pthread_detach()
和 pthread_join()
两个函数。前者使得一个线程被标识为 detached,其信息不会被保留、不可 join,而且在终止后会自动被系统回收(有点像设置 SIGCHLD 的处理方式为 SIG_IGN)。后者则是等待一个线程结束并获取其信息(有点像用于进程等待的 waitpid()
系统调用)。两者都只能在 joinable pthreads 上调用,调用一次之后 pthreads 就不再 joinable,再次调用以上两个函数之一是未定义行为,比如同样的线程标识符可能被其他线程重用,导致操作的线程变了。
torch 分布式程序产生僵尸进程
发表于:
更新于:
聊天记录:
A torch 的分布式程序在一些异常结束的情况下会留下一些僵尸进程 我之前经常是这样 你 kill 掉主进程子进程不会被回收
B 为什么没有会回收?因为父进程没死且没有被回收,难道父进程不是 torch 的主进程,而是整个容器里面的一个活跃进程? 不在容器里使用是否不会出现这种情况? 如果父进程死了,应该由 init 回收 是否是因为容器的起始进程不是 init
A 不知道
B 是在容器中吗
A 不是
关联:
论坛帖子 PyTorch doesn’t free GPU’s memory of it gets aborted due to out-of-memory error - PyTorch Forums 有 PyTorch 开发者回答是 Python 的 multiprocessing 模块有 bug,可能会导致僵尸进程。