Linux 程序中的“线程”经常被描述成一种比进程更轻量的执行单元,但这句话只说明了结果,没有解释实现边界:线程究竟由谁创建?内核如何调度它?多个线程为什么能共享地址空间,又为什么仍然需要独立的栈、寄存器上下文和线程局部数据?
理解这些问题,有助于避免两类常见错误:一是把线程当作“共享一切”的进程,忽略栈和局部状态;二是只会调用 pthread_create,却没有正确处理参数生命周期、退出状态、资源回收和数据竞争。
本文以 Linux 常见的 NPTL/pthreads 使用方式为背景,给出一个可编译运行的线程封装示例。具体实现细节可能随内核、C 库和调度策略变化,文中的接口行为以 POSIX 线程接口及 Linux 常见实现为准。
一、三个概念如何对应
1. 用户态的 pthread
pthread_t 是线程库暴露给应用程序的线程标识。应用通过 pthread_create 创建线程,通过 pthread_join 等待线程结束,通过互斥锁、条件变量和读写锁协调共享数据。
pthread_t 不应被简单等同于 Linux 内核的线程 ID。它是线程库使用的句柄,具体表示形式由实现决定,因此不能假设它一定是整数,也不应直接用 %ld 打印。需要获取内核线程 ID 时,应使用 Linux 提供的 gettid 系统调用接口,并把它与 pthread 句柄区分开。
2. 内核的 LWP
在 Linux 的线程实现中,每个可独立调度的执行流通常对应一个内核任务实体。传统资料常用 LWP,也就是轻量级进程,描述这种执行流。现代 Linux 内核内部使用统一的任务模型管理进程和线程;线程组中的成员共享部分资源,但每个成员仍有自己的调度状态、寄存器上下文和内核栈。
因此,“线程比进程轻量”主要是因为创建线程时可以共享地址空间、文件描述符表等资源,而不是因为线程没有独立的执行上下文。
3. TCB 与线程状态
在线程库层面,TCB 通常表示线程控制块,保存线程库需要维护的信息,例如线程属性、栈相关信息、线程局部存储和退出状态。它不等价于内核的任务结构体。应用只能通过标准接口访问有限的线程状态,不能依赖内部结构布局。
可以用下面的关系理解:
pthread_t:应用看到的线程库句柄。- TCB:线程库为该线程维护的用户态控制信息。
- LWP 或内核任务:内核调度和管理的执行实体。
- 线程组:共享地址空间等资源的一组相关执行实体。
二、创建线程时真正发生了什么
调用 pthread_create 时,线程库会准备线程启动所需的栈、线程局部存储和控制信息,再请求内核创建新的执行实体。新线程从指定的启动函数开始运行,而不是从 main 重新执行。
新线程通常继承创建者的大部分进程属性,例如当前工作目录、环境和文件描述符。与此同时,它拥有独立的栈和寄存器上下文。堆内存、全局变量和文件描述符可以共享,所以多个线程对同一个对象的访问必须遵循同步协议。
线程函数的参数是一个 void *。这个接口很灵活,也带来了生命周期风险:如果把循环变量地址直接传给线程,线程真正运行时该变量可能已经被修改,甚至已经离开作用域。可靠做法是为每个线程准备稳定的参数对象,并在对象不再使用前保证其生命周期。
三、一个可执行的线程封装
下面的示例实现了四件事:创建多个工作线程、为每个线程传递独立参数、保护共享计数器,以及在主线程中统一等待和回收。
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <unistd.h>
struct worker_arg {
int index;
int rounds;
long *counter;
pthread_mutex_t *lock;
};
static long linux_tid(void) {
return (long)syscall(SYS_gettid);
}
static void *worker_main(void *opaque) {
struct worker_arg *arg = opaque;
for (int i = 0; i < arg->rounds; ++i) {
int rc = pthread_mutex_lock(arg->lock);
if (rc != 0) {
fprintf(stderr, "worker %d: lock failed: %d\\n", arg->index, rc);
return NULL;
}
++(*arg->counter);
pthread_mutex_unlock(arg->lock);
}
printf("worker=%d pthread=%lu tid=%ld finished\\n",
arg->index, (unsigned long)pthread_self(), linux_tid());
return NULL;
}
int main(int argc, char **argv) {
int workers = argc > 1 ? atoi(argv[1]) : 4;
int rounds = argc > 2 ? atoi(argv[2]) : 100000;
if (workers <= 0 || workers > 128 || rounds < 0) {
fprintf(stderr, "usage: %s [workers] [rounds]\\n", argv[0]);
return EXIT_FAILURE;
}
pthread_t *threads = calloc((size_t)workers, sizeof(*threads));
struct worker_arg *args = calloc((size_t)workers, sizeof(*args));
if (threads == NULL || args == NULL) {
perror("calloc");
free(threads);
free(args);
return EXIT_FAILURE;
}
long counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int created = 0;
for (int i = 0; i < workers; ++i) {
args[i] = (struct worker_arg){
.index = i,
.rounds = rounds,
.counter = &counter,
.lock = &lock
};
int rc = pthread_create(&threads[i], NULL, worker_main, &args[i]);
if (rc != 0) {
fprintf(stderr, "pthread_create failed: %d\\n", rc);
break;
}
++created;
}
for (int i = 0; i < created; ++i) {
int rc = pthread_join(threads[i], NULL);
if (rc != 0) {
fprintf(stderr, "pthread_join failed: %d\\n", rc);
}
}
printf("counter=%ld expected=%ld\\n",
counter, (long)created * rounds);
pthread_mutex_destroy(&lock);
free(args);
free(threads);
return created == workers ? EXIT_SUCCESS : EXIT_FAILURE;
}
保存为 thread_demo.c 后编译:
gcc -std=c11 -Wall -Wextra -O2 -pthread thread_demo.c -o thread_demo
./thread_demo 4 100000
这里的 -pthread 不只是链接一个库。它会让编译器和链接器采用适合线程程序的设置,构建时应优先使用该选项,而不是只手工追加 -lpthread。
示例中使用互斥锁保护 counter。自增操作看起来只有一行,实际包含读取、计算和写回多个步骤;多个线程并发执行时,如果没有同步,结果就可能丢失更新。expected 只是根据成功创建的线程数计算出的逻辑期望值,不代表任何特定机器上的性能结果。
四、属性配置与线程栈
默认属性通常足以运行普通任务,但服务程序可能需要显式设置栈大小、分离状态或调度属性。配置线程属性时,应检查每个返回值,并在使用完成后销毁属性对象。
pthread_attr_t attr;
int rc = pthread_attr_init(&attr);
if (rc != 0) {
/* 处理初始化失败 */
}
size_t stack_size = 1024 * 1024;
rc = pthread_attr_setstacksize(&attr, stack_size);
if (rc != 0) {
pthread_attr_destroy(&attr);
/* 处理栈大小不合法等情况 */
}
pthread_t tid;
rc = pthread_create(&tid, &attr, worker_main, &args[0]);
pthread_attr_destroy(&attr);
栈设置过小,深层调用、较大的局部数组或递归可能导致栈溢出;设置过大则会增加虚拟地址空间和资源压力。合适的数值取决于任务调用深度、局部变量规模以及系统限制,不能仅凭经验复制到所有程序中。
可连接线程默认需要由其他线程 join,这样才能回收相关资源。分离线程结束后不能再 join,适合“启动后不需要获取退出结果”的任务,但也意味着调用方必须自行设计任务状态、错误报告和关闭流程。不要同时对同一个线程执行 detach 和 join。
五、退出、取消与回收
线程可以通过返回值结束,也可以调用 pthread_exit。如果需要把结果交给 pthread_join,返回对象必须在线程结束后仍然有效,不能返回局部变量地址。常见方式是由堆上分配结果,再由 join 方释放。
线程取消也不是立即强制终止的简单开关。取消通常在取消点生效,线程持有锁或打开文件时还需要清理处理器,否则可能遗留锁状态和资源。对有事务语义的任务,优先设计显式停止标志和条件变量,让线程在安全位置退出,通常更容易审计。
程序关闭时可采用以下顺序:先禁止新任务进入,再通知工作线程停止,唤醒等待中的线程,最后统一 join。如果线程仍可能访问共享对象,就不能提前释放这些对象。
六、常见问题
为什么线程 ID 打印出来不一样?
pthread_self() 返回的是线程库句柄,gettid 返回的是内核线程 ID,两者用途和表示形式不同。日志中可以同时记录它们,但不要拿一个去调用只接受另一个的接口。
为什么加了锁仍然出现数据异常?
首先检查所有读写路径是否都使用同一把锁;只保护写入、不保护读取仍可能产生竞态。其次确认是否存在多个副本、提前释放或越界写。可以使用 ThreadSanitizer 做辅助检查:
gcc -std=c11 -g -O1 -fsanitize=thread -fno-omit-frame-pointer \\
-pthread thread_demo.c -o thread_demo_tsan
./thread_demo_tsan 4 1000
该工具适合发现一部分数据竞争,但工具报告需要结合程序语义判断,不能把没有报告当成绝对正确。
为什么线程数量越多不一定越快?
线程会竞争 CPU、缓存、锁和内存带宽。计算密集型任务的并发度通常受可用 CPU 资源约束;阻塞型任务可以使用更多线程,但仍需观察队列长度、上下文切换和外部依赖。并发度应通过任务模型和实际观测确定,而不是固定追求更大的线程数。
如何定位线程卡住的位置?
先确认线程是否仍存活,再查看线程级 CPU、等待状态和调用栈。Linux 下可结合 ps -L、top -H、调试器和核心转储分析。若问题涉及锁,记录锁获取前后的日志、线程 ID 和任务标识,比只记录“开始执行”更有帮助。
总结
Linux 线程既不是没有独立状态的函数调用,也不是完全独立的进程。用户态的 pthread 接口、线程库维护的 TCB,以及内核中可调度的任务实体,分别处在不同层次。掌握这种分层关系后,线程创建、栈配置、ID 记录和问题排查会更清晰。
工程上真正重要的不是把线程启动起来,而是明确参数生命周期、共享数据边界、停止协议和资源回收路径。用稳定的参数对象、统一的同步规则和成对的创建回收操作构建最小封装,再根据任务类型决定栈大小、分离状态和并发度,才能让线程程序从“能运行”走向可维护、可诊断。