
linux多线程编程怎么用:从准备到完成的实操步骤
Linux 多线程编程常用于把可以并行执行的工作分配给多个执行单元,例如批量处理文件、并行计算、网络服务中的并发请求,以及后台任务与主流程并行运行。线程共享同一进程的地址空间,创建和共享数据通常比创建独立进程方便;相应地,多个线程也可能同时读写同一份数据,带来数据竞争、死锁和难以复现的错误。
本文使用 C 语言和 POSIX 线程接口(通常简称 pthread)介绍一套可实际运行的流程:准备编译环境、创建线程、传递参数、等待线程结束、处理共享数据,以及检查常见错误。示例重点是理解线程生命周期和边界,而不是宣称多线程总能让程序更快。
一、先判断问题是否适合多线程
多线程适合有多个相对独立任务、且并行执行能带来实际收益的场景。常见情况包括:
- 计算密集型任务:大批量图像处理、数值计算、压缩等工作,如果任务可以分块且计算时间足够长,可以考虑由多个线程处理不同部分。
- I/O 等待较多的任务:网络服务或文件处理程序可以让一个线程等待 I/O 时,其他线程继续处理工作。不过,线程并不是唯一方案,事件循环、异步 I/O 或进程池也可能更合适。
- 后台工作:主线程负责交互或调度,工作线程处理队列中的任务。此类程序通常还需要设计任务队列、停止机制和错误传递方式。
多线程不一定能提高速度。若任务很短,线程创建、调度和同步的开销可能超过计算本身;若工作主要受磁盘、网络或内存带宽限制,增加线程数量也可能无济于事。线程越多还会消耗更多栈空间和系统资源。实际开发中应先测量单线程版本,再依据数据决定是否并行,并通过基准测试检查收益。
还要注意:线程共享进程内的全局变量、堆内存和打开的文件描述符等资源,但各自有独立的线程栈和执行状态。共享方便并不意味着可以不加约束地同时访问数据。
二、准备环境并编译一个最小程序
在常见 Linux 发行版中,安装 GCC 或其他兼容的 C 编译器及其开发工具即可。POSIX 线程接口由系统 C 库提供。编译时应使用 -pthread,让编译器和链接器按线程程序的需要处理程序;不要把它简单替换成只在链接阶段添加的参数。
# Debian / Ubuntu
sudo apt install build-essential
# Fedora
sudo dnf install gcc
# 编译
gcc -std=c11 -Wall -Wextra -Wpedantic -O2 -pthread main.c -o main
不同发行版的软件包名称可能不同。若系统尚未安装编译工具,应按所用发行版的文档安装。以下最小例子创建一个线程,主线程等待它结束:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
static void *worker(void *arg)
{
const char *message = arg;
printf("工作线程收到:%s\n", message);
return NULL;
}
int main(void)
{
pthread_t thread;
const char *message = "hello";
int error = pthread_create(&thread, NULL, worker, (void *)message);
if (error != 0) {
fprintf(stderr, "pthread_create 失败,错误码:%d\n", error);
return EXIT_FAILURE;
}
error = pthread_join(thread, NULL);
if (error != 0) {
fprintf(stderr, "pthread_join 失败,错误码:%d\n", error);
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
将代码保存为 main.c,使用前面的编译命令编译,再运行 ./main。输出文字来自工作线程,但它与主线程谁先执行并无固定保证。pthread_join 会等待指定的可连接线程结束,因此程序不会在工作线程尚未完成时正常走到末尾。
三、理解 pthread 的函数签名和错误处理
线程函数的类型必须是 void *(*)(void *):接收一个 void * 参数,并返回一个 void *。常见写法是在参数中传入结构体指针,让线程拿到所需的多个值;线程结束时可以返回结果指针,也可以直接把结果写入事先分配好的结果结构体。
pthread_create 的基本形式如下:
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void *),
void *arg);
第一个参数用于接收线程标识,第二个参数用于设置线程属性;传入 NULL 表示使用默认属性。第三个参数是线程入口函数,第四个参数会原样传给该函数。
需要特别区分 pthread 接口与许多传统 C/POSIX 函数的错误报告方式。像 pthread_create、pthread_join 这样的函数,失败时通常直接返回非零错误码,而不是返回 -1 后通过 errno 报错。因此应检查返回值并记录它本身。不要在这些接口失败后只打印 perror(),也不要假定读取 errno 就能得到 pthread 函数的错误原因。
创建线程失败可能与进程或系统可用资源有关,但不存在一个适用于所有 Linux 系统、所有程序的固定线程数量上限。可用线程数会受系统配置、用户和进程资源限制、内存、线程栈大小等因素影响。程序应检查创建是否成功,而不是依赖一个未经验证的“系统最多支持多少线程”数字。
四、管理线程的生命周期
创建后要么 join,要么 detach
新建线程通常是可连接的。主线程或其他线程可以用 pthread_join 等待它结束,并在需要时取得其返回值。可连接线程结束后,相关线程资源要等到被连接后才能回收,所以应为每个可连接线程安排明确的回收路径。
如果程序不需要等待某个线程,也不打算取得它的返回值,可以在合适的时机使用 pthread_detach,使其在结束时自动释放相应资源。分离线程不能再通过 pthread_join 等待。对线程已经 join 或 detach 的状态管理要清晰,避免重复回收或遗漏回收。
不要把“线程通常很快就会结束”当作不 join 的理由。若创建了许多可连接线程却长期不回收,线程资源可能逐渐耗尽。
传参时保证对象活得足够久
下面这种写法容易出错:循环里创建线程,并把循环变量的地址传给所有线程。循环变量会继续变化,线程也可能在参数使用之前才开始运行;此时多个线程可能读到同一个、甚至已经失效的对象。
/* 不推荐:多个线程收到同一个循环变量地址 */
for (int i = 0; i < count; ++i) {
pthread_create(&threads[i], NULL, worker, &i);
}
更可靠的做法是为每个线程准备独立参数结构体,并确保这些结构体至少在线程完成使用之前一直有效。可以在堆上分配参数,并在线程完成后释放;也可以像后面的示例一样,先分配一组连续的参数结构体,在所有线程 join 之后再释放。栈上对象也能传给线程,但前提是其作用域和生命周期覆盖所有线程对它的访问,不能在函数返回后仍让线程使用。
五、可运行示例:把求和任务分给多个线程
下面的程序把从 0 到 n-1 的整数分成若干连续区间,每个线程计算自己负责部分的和,主线程 join 所有工作线程后再汇总结果。每个线程写入独立的结果字段,因此不需要用互斥锁保护这些字段。
程序限制输入范围,是为了让示例的无符号 64 位结果不会溢出,也为了避免演示时意外进行过长计算。这是示例自身采用的应用层限制,不是 Linux 或 pthread 的普遍参数限制。线程数上限 256 同样是本例的保守策略,不代表系统能力上限;实际应用应根据任务、资源和测试结果设定合适策略。
#include <errno.h>
#include <inttypes.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_N UINT64_C(1000000000)
#define MAX_THREADS 256
struct task {
uint64_t begin;
uint64_t end;
uint64_t result;
};
static void *sum_range(void *arg)
{
struct task *task = arg;
uint64_t sum = 0;
for (uint64_t i = task->begin; i < task->end; ++i) {
sum += i;
}
task->result = sum;
return NULL;
}
/* 成功时写入结果并返回 0,失败时返回非零。 */
static int parse_u64(const char *text, uint64_t *value)
{
char *end = NULL;
unsigned long long parsed;
if (text == NULL || text[0] == '\0' || text[0] == '-') {
return -1;
}
errno = 0;
parsed = strtoull(text, &end, 10);
if (errno == ERANGE || end == text || *end != '\0') {
return -1;
}
if (parsed > UINT64_MAX) {
return -1;
}
*value = (uint64_t)parsed;
return 0;
}
int main(int argc, char **argv)
{
uint64_t n;
uint64_t thread_count_u64;
pthread_t *threads;
struct task *tasks;
size_t created = 0;
uint64_t total = 0;
if (argc != 3 ||
parse_u64(argv[1], &n) != 0 ||
parse_u64(argv[2], &thread_count_u64) != 0) {
fprintf(stderr, "用法:%s <n> <线程数>\n", argv[0]);
return EXIT_FAILURE;
}
if (n == 0 || n > MAX_N ||
thread_count_u64 == 0 ||
thread_count_u64 > MAX_THREADS ||
thread_count_u64 > n) {
fprintf(stderr,
"参数范围:1 <= n <= %" PRIu64
",且 1 <= 线程数 <= %d、线程数 <= n\n",
MAX_N, MAX_THREADS);
return EXIT_FAILURE;
}
size_t thread_count = (size_t)thread_count_u64;
threads = calloc(thread_count, sizeof(*threads));
tasks = calloc(thread_count, sizeof(*tasks));
if (threads == NULL || tasks == NULL) {
fprintf(stderr, "内存分配失败\n");
free(threads);
free(tasks);
return EXIT_FAILURE;
}
uint64_t base = n / thread_count_u64;
uint64_t remainder = n % thread_count_u64;
uint64_t next = 0;
for (size_t i = 0; i < thread_count; ++i) {
uint64_t length = base + (i < remainder ? 1 : 0);
tasks[i].begin = next;
tasks[i].end = next + length;
tasks[i].result = 0;
next += length;
int error = pthread_create(&threads[i], NULL, sum_range, &tasks[i]);
if (error != 0) {
fprintf(stderr, "pthread_create 失败:%s\n", strerror(error));
/* 已成功创建的线程仍需回收。 */
for (size_t j = 0; j < created; ++j) {
int join_error = pthread_join(threads[j], NULL);
if (join_error != 0) {
fprintf(stderr, "pthread_join 失败:%s\n",
strerror(join_error));
free(threads);
free(tasks);
return EXIT_FAILURE;
}
}
free(threads);
free(tasks);
return EXIT_FAILURE;
}
++created;
}
for (size_t i = 0; i < created; ++i) {
int error = pthread_join(threads[i], NULL);
if (error != 0) {
fprintf(stderr, "pthread_join 失败:%s\n", strerror(error));
free(threads);
free(tasks);
return EXIT_FAILURE;
}
total += tasks[i].result;
}
printf("0 到 %" PRIu64 " 的和为:%" PRIu64 "\n", n - 1, total);
free(threads);
free(tasks);
return EXIT_SUCCESS;
}
保存为 sum_threads.c,编译并运行:
gcc -std=c11 -Wall -Wextra -Wpedantic -O2 -pthread sum_threads.c -o sum_threads
./sum_threads 1000000 4
对于 n = 1000000,结果应为 499999500000。线程获得的区间长度可能相差 1:程序通过商和余数分配任务,保证所有区间连续、不重叠,并且覆盖 [0, n)。每个任务的数据结构都来自 tasks 数组,只有在所有线程 join 后才释放,因此线程使用参数时对象仍然有效。
本例先依次创建线程,再逐个 join。线程会与创建过程并行运行,join 的次序不决定它们的实际执行顺序。遇到创建失败时,程序会等待此前已经成功创建的线程结束,再释放参数内存。若 join 发生异常,本例会以失败状态退出;更复杂的长期运行服务还应设计更细致的错误恢复和资源清理策略。
六、共享数据如何避免数据竞争
当多个线程读写同一变量,且访问之间没有正确同步时,就可能发生数据竞争。不能仅凭变量是整数、写操作看似很短,或某次测试没有出错,就认定访问安全。C 语言中的数据竞争会导致未定义行为,编译器优化也可能让结果与直觉不同。
最常见的保护方式是互斥锁。多个线程在访问共享状态前加锁,完成后解锁。对于可静态初始化的全局互斥锁,可以这样写:
#include <pthread.h>
#include <stdio.h>
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static long shared_count = 0;
static void increment(void)
{
pthread_mutex_lock(&lock);
++shared_count;
pthread_mutex_unlock(&lock);
}
static void print_count(void)
{
pthread_mutex_lock(&lock);
printf("%ld\n", shared_count);
pthread_mutex_unlock(&lock);
}
示例省略了锁操作的错误处理;正式程序应检查相关接口的返回值,并确保所有成功加锁的路径最终都会解锁。不要在持锁期间执行可能长时间等待的操作,例如阻塞式 I/O 或等待另一个需要同一把锁的任务,否则会让其他线程长时间停滞,甚至形成死锁。
使用互斥锁时应尽量缩小临界区,只保护必须一致访问的数据。锁的顺序也应保持一致:如果线程 A 先拿锁 1 再拿锁 2,而线程 B 先拿锁 2 再拿锁 1,就可能互相等待。若确实需要多把锁,应设计并遵守统一的加锁顺序。
七、用条件变量等待状态变化
条件变量常与互斥锁配合,用来等待某个共享条件成立,例如任务队列从空变为非空。条件变量本身不是“事件计数器”;通知不能替代对共享状态的检查。等待方应在持有对应互斥锁时检查条件,并用循环调用 pthread_cond_wait:
pthread_mutex_lock(&lock);
while (!condition_is_ready) {
pthread_cond_wait(&condition, &lock);
}
/* 条件成立,在锁保护下处理共享状态 */
process_shared_state();
pthread_mutex_unlock(&lock);
pthread_cond_wait 会在等待期间释放互斥锁,并在返回前重新获得它。使用 while 而不是 if,是因为线程被唤醒后仍需重新确认条件;唤醒本身不保证条件一定成立。修改受保护的状态后,生产者通常在锁保护下调用 pthread_cond_signal 通知一个等待者,或调用 pthread_cond_broadcast 通知所有等待者。具体选哪一个,要看状态变化是否可能满足多个等待线程。
八、常见错误与排查办法
- 认为创建顺序就是执行顺序:线程由系统调度,谁先运行没有一般性保证。需要顺序时应使用锁、条件变量、信号量或其他同步设计,而不是依赖时序运气。
- 忘记等待或分离线程:可连接线程结束后没有 join,会留下未回收资源。为每个线程明确选择 join 或 detach,并记录其状态。
- 把临时对象地址传给线程:局部变量离开作用域后,线程仍可能访问它。检查每个参数对象的生命周期,必要时使用独立分配的对象。
- 以为
volatile可以替代锁:volatile主要用于特定的可观察访问需求,不能提供线程间互斥,也不能保证复合操作原子性。并发共享数据应使用 pthread 同步原语或合适的原子操作。 - 忽略返回值:创建失败后继续使用未初始化的线程标识,或 join 失败后假定线程结果可读,都会引入错误。检查返回值,并按已成功创建的线程数量清理。
- 线程过多:每个线程都要占用调度和栈等资源。不要为每个微小任务都创建一个线程;大批短任务通常更适合固定大小的线程池或其他任务调度方式。
- 共享一个非线程安全对象:即使程序本身使用多线程,调用的库函数或自定义对象也未必支持并发访问。查阅所用接口的线程安全约定,必要时为对象加锁或为每个线程使用独立实例。
- 多线程中随意使用
fork:多线程进程调用fork后,子进程只保留调用fork的线程,但地址空间可能包含其他线程加锁后的状态。此后直到exec,子进程可安全调用的函数受到严格限制;应按 POSIX 规则设计,避免把复杂的普通代码放在 fork 与 exec 之间。
排查问题时,可以先缩小并发范围,让任务数量和线程数都较小,再增加日志或用调试器观察线程状态。日志本身可能改变线程时序,因此“加了日志后问题消失”并不能证明问题已解决。对于数据竞争,可考虑使用编译器提供的线程检测工具进行辅助检查;工具和运行环境需与代码及编译方式匹配,检测结果也应结合实际同步设计分析。
九、线程数和资源边界
pthread 不保证调用者总能创建指定数量的线程。内核、进程资源限制、内存压力、线程栈大小及系统配置都可能影响创建结果。线程默认栈大小由实现和系统环境决定,不应假设在所有机器上都相同;递归较深或局部变量很大的线程函数要特别评估栈需求。若要通过线程属性调整栈大小,应查阅本机 pthread 文档,验证参数范围并处理错误。
使用用户输入决定线程数时,应设置适合应用的上限,检查输入格式和范围,并将资源耗尽风险纳入设计。示例中的 256 只是为了让演示规模可控,不是对所有服务器或 Linux 系统都合适的推荐值。实际值应通过负载测试和资源监控确定。对外提供服务的程序还应避免让不可信请求直接触发无限制的线程创建;线程数量限制属于资源管理措施,不是安全边界本身。
十、线程安全不等于安全防护
多线程技术本身不是权限控制、隔离或输入校验机制。线程共享同一进程的权限和大部分地址空间;某个线程受到恶意输入影响时,错误可能波及整个进程。不要把互斥锁当作访问授权机制,也不要把线程隔离当作进程隔离。
如果程序处理不可信输入,应在输入进入并发工作流程之前校验格式和范围,并限制并发任务数、排队长度和资源消耗。共享对象的锁应保护一致性,但不能替代对路径、文件权限、网络请求或业务权限的检查。处理敏感数据时,还需要结合操作系统权限、进程隔离、日志策略和应用自身的安全设计。
十一、从示例走向实际程序
开始使用 pthread 时,可以按下面的顺序推进:先把工作拆分为可以独立执行的任务;再确定哪些数据由线程独占、哪些数据需要共享;接着为共享状态设计互斥锁或条件变量;随后明确参数对象、线程结果和退出方式的生命周期;最后检查创建失败、等待失败和资源清理路径。
完成后,用单线程版本作为正确性和性能基线,测试不同任务规模和线程数量,并重点检查边界输入、创建失败处理及并发访问。多线程的价值来自明确的任务划分和正确的同步,而不是线程数量本身。先保证结果正确、资源可回收,再依据测量结果决定是否增加并发度。




