0%

线程号在整个系统是唯一的,而 kill 命令也疑似可以精准发送信号给目标线程(见下方代码的测试 1;测试时只保留测试 1 和 2 其中一个,注释掉另外一个)。特殊情况:

  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 以在其终止后回收资源(不再是僵尸)

https://stackoverflow.com/questions/11130828/after-main-thread-call-pthread-exit-it-turn-into-zombie-something-wrong 中,有人解释:

API

#include <pthread.h>

int pthread_cancel(pthread_t thread);

线程取消状态和线程取消类型

它们分别可以用 pthread_setcancelstatepthread_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 手册:

主要内容

  • 线程和传统 UNIX API 之间的交互(信号、进程控制原语)
  • Linux 上的两个线程实现

线程栈的大小

在 x86_64-linux-gnu 上,除了主线程,其他线程的缺省大小都是 2MB。

Tip

2025/3/8 省流:

  1. 缺省:主线程一般 8MB(ulimit -s 看,单位是 KB 不是 B),非主线程缺省 2MB。
  2. 最小: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 的参数。

  1. 可以使用 pthread_attr_setstacksize() 设置线程栈的大小。
  2. 也可以用 pthread_attr_setstack() 同时设置线程栈的大小和地址,但是这样做会损害可移植性。

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() 的空实现(总会设置 errnoENOSYS 并返回 -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()

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);

互斥量

互斥量 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_INITIALIZERPTHREAD_RECURSIVE_MUTEX_INITIALIZER_NPPTHREAD_ERRORCHECK_MUTEX_INITIALIZER_NP 之一,或者 pthread_mutex_init() 来初始化一个互斥量。只有 PTHREAD_MUTEX_INITIALIZER 是标准的,另外两个都是 Linux 提供的非标准的宏。

虽然书上说前者是用来初始化静态分配的互斥量的,但是没有实现禁止用其初始化在堆上分配的互斥量,也无法确定“静态分配”指的是有稳定地址还是一定要分配在静态数据存储区。另外,前面几个宏只能使用默认的属性,想要对属性进行更加精细的控制,则需要使用 pthread_mutex_init()

初始化一个已经初始化过的互斥量是 UB。

线程共享了什么?

实际上就是 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() 用于支持线程的标志

线程的私有数据是什么?

书中列举了一部分:

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,再次调用以上两个函数之一是未定义行为,比如同样的线程标识符可能被其他线程重用,导致操作的线程变了。

记账

记账功能打开后,系统会在每个进程结束后记录一条账单信息。标准工具 sa(8) 对账单文件进行汇总,lastcomm(1) 则就先前执行的命令列出相关信息。

Tip

Linux 系统的进程记账功能属于可选内核组件,可以通过 CONFIG_BSD_PROCESS_ACCT 选项进行配置。 在 Debian 上,需要使用 apt install acct 来安装(否则 man 8 sawhich lastcomm 都找不到)。

特权进程可以使用 acct 系统调用来打开或关闭记账功能,应用程序则很少使用到这种调用。

系统调用 clone()

clone() 的地位和 fork()vfork() 一样,它们在内核中都是用 do_fork() 来实现的,但是它能支持比后两个函数更加精细的控制,因此也被用作线程实现的方式。我们一般使用的 clone() 是 glibc 的包装函数,它和内核提供的 SYS_clone 系统调用有一点差异。

/* Prototype for the glibc wrapper function */

#define _GNU_SOURCE
#include <sched.h>

int clone(int (*fn)(void *_Nullable), void *stack, int flags,
         void *_Nullable arg, ...  /* pid_t *_Nullable parent_tid,
                                      void *_Nullable tls,
                                      pid_t *_Nullable child_tid */ );

exec() 函数

SYNOPSIS
#include <unistd.h>

extern char **environ;

int execl(const char *pathname, const char *arg, ...
               /*, (char *) NULL */);
int execlp(const char *file, const char *arg, ...
               /*, (char *) NULL */);
int execle(const char *pathname, const char *arg, ...
               /*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]); /* GNU 扩展 */

/* 不知道为什么单独出来一个手册页,和上面没有放在一起? */
int execve(const char *pathname, char *const _Nullable argv[],
          char *const _Nullable envp[]);

函数命名规则:

  1. 其中 exec 前缀是一定有的。
  2. 然后用 lva_list 形式)或者 v(数组形式)表示命令行参数,这也是一定有的。
  3. (可选)如果允许在 PATH 中搜索(可执行)文件,而不是用绝对或相对路径搜索文件,则有 p 后缀。
  4. (可选)如果允许(以字符串数组的形式)设置环境变量,则有 e 后缀。

如果在设置环境变量的 exec() 系列系统调用中没有提及 PATH 环境变量,则使用默认 PATH。根据系统不同,默认的 PATH 可能不同,常见的 PATH.:/usr/bin:/bin。处于安全考虑,特权用户的 PATH 中常常没有 .(当前工作目录)。

If this variable isn’t defined, the path list defaults to a list that includes the directories returned by confstr(_CS_PATH) (which typically returns the value “/bin:/usr/bin”) and possibly also the current working directory