
linux多线程编程使用教程:新手完成第一张作品的方法
多线程编程听起来像是要同时做很多事,但入门时不必先设计复杂系统。先写一个小程序:把一组数字分成几段,交给多个线程分别求和,最后由主线程合并结果。这个练习会用到线程创建、参数传递、等待线程结束和共享数据边界,足以搭起第一份可运行的作品。
本文使用 C 语言和 POSIX 线程接口(pthread)。这些接口常用于 Linux,也适用于其他提供 POSIX 线程的系统。示例中的线程数由命令行参数控制,并设置了上限;实际部署时还应根据机器资源、任务性质和应用的整体负载进一步限制并发数。
一、什么时候适合使用多线程
线程是同一进程内的执行路径。一个进程中的线程共享地址空间、全局变量、堆内存和打开的文件等资源,但每个线程有自己的栈和执行状态。这让线程适合处理可以并行或需要并发等待的工作,例如:
- 把大量相互独立的计算分块处理,再汇总结果。
- 服务器同时处理多个连接或任务,但需要控制并发数量。
- 程序需要在等待网络、磁盘等 I/O 时继续响应其他工作。
- 后台工作与前台交互并行进行,例如界面程序中的耗时任务。
并不是所有程序都能因多线程变快。若任务很小,创建和协调线程的开销可能高于计算本身;若工作主要等待 I/O,增加线程也不一定带来更高吞吐量;若所有线程争抢同一把锁,实际执行仍可能接近串行。多线程还会增加调试难度,所以应先确认工作可以安全拆分,再通过测量判断是否值得使用。
二、准备环境并运行第一个程序
需要安装 C 编译器和开发工具。常见发行版可通过包管理器安装 GCC 或 Clang。例如 Debian、Ubuntu 系统通常可以安装 build-essential;Fedora 可安装 gcc 和相关开发工具。以下示例保存为 first_thread.c。
程序把从 1 到 N 的整数切成若干连续区间,每个线程计算自己的区间之和,主线程等待所有工作线程结束后汇总。为避免示例中的加法发生有符号整数溢出,程序限制 N 不超过 1000000;线程数也限制在 1 到 64 之间。这些是示例自身的输入限制,不是 pthread API 的通用上限。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
enum {
MAX_N = 1000000,
MAX_THREADS = 64
};
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;
}
static int parse_uint(const char *text, unsigned long *value)
{
char *end = NULL;
errno = 0;
unsigned long parsed = strtoul(text, &end, 10);
if (errno != 0 || end == text || *end != '\0') {
return -1;
}
*value = parsed;
return 0;
}
int main(int argc, char **argv)
{
if (argc != 3) {
fprintf(stderr, "用法: %s N 线程数\n", argv[0]);
return 2;
}
unsigned long n_arg, count_arg;
if (parse_uint(argv[1], &n_arg) != 0 ||
parse_uint(argv[2], &count_arg) != 0 ||
n_arg < 1 || n_arg > MAX_N ||
count_arg < 1 || count_arg > MAX_THREADS) {
fprintf(stderr, "N 必须为 1..%d,线程数必须为 1..%d\n",
MAX_N, MAX_THREADS);
return 2;
}
size_t count = (size_t)count_arg;
pthread_t *threads = calloc(count, sizeof(*threads));
struct task *tasks = calloc(count, sizeof(*tasks));
if (threads == NULL || tasks == NULL) {
perror("calloc");
free(threads);
free(tasks);
return 1;
}
uint64_t n = (uint64_t)n_arg;
uint64_t chunk = (n + count - 1) / count;
size_t created = 0;
for (size_t i = 0; i < count; ++i) {
uint64_t begin = (uint64_t)i * chunk + 1;
if (begin > n) {
break;
}
uint64_t end = begin + chunk - 1;
if (end > n) {
end = n;
}
tasks[i].begin = begin;
tasks[i].end = end;
int rc = pthread_create(&threads[i], NULL, sum_range, &tasks[i]);
if (rc != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(rc));
break;
}
++created;
}
uint64_t total = 0;
int failed = (created != count);
for (size_t i = 0; i < created; ++i) {
int rc = pthread_join(threads[i], NULL);
if (rc != 0) {
fprintf(stderr, "pthread_join: %s\n", strerror(rc));
failed = 1;
} else {
total += tasks[i].result;
}
}
if (!failed) {
printf("1 到 %llu 的和是 %llu(使用 %zu 个线程)\n",
(unsigned long long)n,
(unsigned long long)total,
count);
}
free(tasks);
free(threads);
return failed ? 1 : 0;
}
编译时使用 -pthread:
gcc -std=c11 -Wall -Wextra -Wpedantic -O2 -pthread first_thread.c -o first_thread
运行示例:
./first_thread 100000 4
预期结果为:
1 到 100000 的和是 5000050000(使用 4 个线程)
不同编译器或系统的诊断信息格式可能不同,但成功运行时的求和结果应一致。也可以传入一个线程,或让线程数大于 N,程序仍会处理;当实际区间用完时,不会再创建空任务。
三、逐步看懂核心接口
1. 线程函数的形式
pthread_create 要求线程入口函数符合 void *(*)(void *) 这一形式:接收一个 void * 参数,并返回一个 void *。示例中的 sum_range 接收任务结构体地址,在该结构体上写入结果,最后返回 NULL。
2. 创建线程
调用形式如下:
int rc = pthread_create(&thread, NULL, worker, argument);
第一个参数用于接收线程标识;第二个参数传递线程属性,传入 NULL 表示使用默认属性;第三个参数是入口函数;第四个参数是传给入口函数的指针。调用成功返回 0,失败时返回错误码。
要注意,pthread 函数通常直接返回错误码,而不是通过 errno 报错。因此应检查返回值,并用 strerror(rc) 解释错误;不能只检查 errno,也不要把错误码误当成 -1。
3. 等待线程结束
pthread_join(thread, NULL) 等待指定线程结束,并回收可连接线程的相关资源。主线程在所有 join 成功之前,不应释放线程仍在使用的参数,也不应读取尚未完成的结果。示例为每个线程单独分配任务结构体,并在主线程全部等待后汇总结果。
线程函数的返回指针可以通过 pthread_join 的第二个参数接收,不过返回的指针必须在等待方读取时仍然有效。不要返回线程栈上的局部变量地址:线程退出后该对象的生命周期结束,使用它会造成未定义行为。较简单的设计是让调用方提供结果存储位置,如示例所示。
四、参数生命周期与共享数据边界
多线程程序最常见的问题之一,是线程还在运行,主线程就修改或释放了参数。传给线程的指针只是地址,不会自动复制它指向的数据。被指向的对象必须在线程访问期间一直有效。
例如,以下写法有问题:
for (int i = 0; i < count; ++i) {
pthread_create(&threads[i], NULL, worker, &i);
}
所有线程拿到的是同一个变量 i 的地址。循环仍在修改它,线程读取时可能看到不同的值,也可能与主线程并发访问同一个非原子对象,形成数据竞争。结果不可靠,属于未定义行为。
可靠做法是为每个线程准备各自独立、生命周期足够长的参数对象。本文示例的 tasks 数组在创建线程前分配,每个线程使用独立下标对应的结构体;数组在所有线程结束之后才释放。若使用局部数组,也必须保证函数不会在线程结束前返回。
共享数据则要分清“多个线程都能访问”和“多个线程可以同时修改”。示例中的结果由每个线程写入不同的 tasks[i].result,主线程在 pthread_join 之后才读取,没有多个线程同时写同一个结果变量。若多个线程对同一普通整数执行自增,即使单次操作看起来很简单,也可能因读、改、写交错而丢失更新。
五、用互斥锁保护共享计数器
多个线程需要共同更新同一份状态时,可以使用互斥锁。下面的完整示例让四个线程各执行 100000 次计数。计数器只在持有锁时读写;主线程等待所有线程结束后再打印结果。
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#define THREAD_COUNT 4
#define ITERATIONS 100000
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static long counter = 0;
static void *increment(void *arg)
{
(void)arg;
for (int i = 0; i < ITERATIONS; ++i) {
int rc = pthread_mutex_lock(&lock);
if (rc != 0) {
fprintf(stderr, "pthread_mutex_lock: %s\n", strerror(rc));
return NULL;
}
++counter;
rc = pthread_mutex_unlock(&lock);
if (rc != 0) {
fprintf(stderr, "pthread_mutex_unlock: %s\n", strerror(rc));
return NULL;
}
}
return NULL;
}
int main(void)
{
pthread_t threads[THREAD_COUNT];
int created = 0;
for (int i = 0; i < THREAD_COUNT; ++i) {
int rc = pthread_create(&threads[i], NULL, increment, NULL);
if (rc != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(rc));
break;
}
++created;
}
int failed = (created != THREAD_COUNT);
for (int i = 0; i < created; ++i) {
int rc = pthread_join(threads[i], NULL);
if (rc != 0) {
fprintf(stderr, "pthread_join: %s\n", strerror(rc));
failed = 1;
}
}
if (!failed) {
printf("counter = %ld\n", counter);
}
pthread_mutex_destroy(&lock);
return failed ? 1 : 0;
}
编译运行:
gcc -std=c11 -Wall -Wextra -Wpedantic -O2 -pthread counter.c -o counter
./counter
正常情况下输出 counter = 400000。互斥锁保证同一时刻只有一个线程进入临界区,但锁也会带来等待和开销。应尽量缩小临界区,不要在持锁期间做耗时 I/O、等待网络响应或调用可能阻塞很久的函数。
上例中的错误处理用于说明 pthread 函数的返回值规则。生产代码还要认真处理线程函数内部的失败:若线程无法完成任务,应将错误状态写入专属结果结构,或通过经过同步保护的共享状态通知其他线程。不能假设线程函数返回 NULL 就代表任务一定成功。
六、线程属性、栈与资源限制
使用默认属性创建线程通常足够入门。线程属性可用于设置分离状态、栈大小等,但这些设置受系统实现和资源条件约束。不要未经测量就把线程栈调得很小:递归调用、较大的局部数组或较深的调用链可能耗尽栈空间,造成难以诊断的崩溃。
线程数量没有一个对所有 Linux 系统都适用的固定上限。创建线程会消耗内存和内核资源,系统的用户限制、进程限制和当前资源状况都可能影响创建是否成功。线程过多不仅可能让 pthread_create 失败,还会增加调度切换、内存占用和锁竞争。因此,服务程序通常采用有界线程池或任务队列,而不是为每个输入无条件创建一个新线程。
示例把最大线程数限定为 64,只是为了让新手实验时避免意外传入极大值。真实应用应根据计算任务、可用 CPU、请求负载、内存预算和延迟目标设定合理上限,并监测线程创建失败和队列积压。CPU 密集型任务的线程数可从可用处理器数量附近开始测量;I/O 密集型任务的合适并发数则取决于等待时间和资源限制,不能套用一个固定公式。
七、常见错误清单
- 忘记使用
-pthread。 编译或链接时应使用该选项;它会为编译与链接阶段启用相应的线程支持。现代 GCC、Clang 工具链通常应优先使用它,而不是只手动添加一个库参数。 - 把线程函数写成普通的
int函数。 入口函数必须符合 pthread 规定的参数和返回类型,不能靠强制转换函数指针来掩盖类型不匹配。 - 忘记等待线程。 对需要获取结果或回收资源的线程,应正确调用
pthread_join。如果线程被创建为分离状态,不能再对它调用join;分离线程结束后由系统回收其线程资源,但应用仍须妥善管理它访问的对象。 - 让多个线程无同步地写同一变量。 这不是单纯的“偶尔算错”,而是数据竞争;在 C 语言中,两个线程并发访问同一内存位置且至少一个是写操作,若没有适当同步,程序行为未定义。
- 持有锁时再次等待自己。 普通互斥锁不能随意重复加锁;错误的锁顺序还可能让多个线程互相等待,形成死锁。多个锁应制定一致的获取顺序,并保证所有成功加锁的路径最终都会解锁。
- 误用
volatile代替同步。volatile不会让复合操作变成原子操作,也不会建立线程间所需的同步关系。共享可变状态应使用互斥锁、条件变量或 C11 原子操作等合适机制。 - 忽略创建失败后的清理。 如果只创建成功一部分,应等待已经成功创建的线程,再释放它们可能使用的数据。示例记录了成功创建的数量,并只对这些线程调用
join。
八、条件变量适合解决什么问题
互斥锁负责保护共享状态,条件变量用于让线程等待某个状态发生变化,例如工作队列从空变为非空。条件变量必须配合互斥锁使用;等待线程应在循环中检查条件,因为唤醒不等于条件必然满足,也可能发生虚假唤醒。
pthread_mutex_lock(&lock);
while (!condition) {
pthread_cond_wait(&cond, &lock);
}
/* 此处在持锁状态下处理满足条件后的状态 */
pthread_mutex_unlock(&lock);
pthread_cond_wait 会原子地释放互斥锁并进入等待;被唤醒后重新取得锁才返回。修改条件的线程也应持有同一把锁更新状态,再调用 pthread_cond_signal 或 pthread_cond_broadcast。初学时若任务可以简单地让主线程汇总,优先使用 pthread_join,不要为了“用上更多接口”而增加不必要的同步复杂度。
九、调试与验证建议
先用小输入验证边界:N 为 1、线程数为 1、线程数大于 N,以及非法参数。再用不同线程数运行相同输入,确认结果一致。开启编译器警告有助于发现类型和格式问题;开发阶段还可以使用 AddressSanitizer 检查部分内存错误,以及 ThreadSanitizer 检查部分数据竞争。它们不能证明程序绝对正确,也可能增加运行开销,不应把插桩构建直接当作正式性能数据。
gcc -std=c11 -Wall -Wextra -Wpedantic -O1 -g -pthread \
-fsanitize=thread first_thread.c -o first_thread_tsan
./first_thread_tsan 100000 4
不同编译器、系统和运行环境对 ThreadSanitizer 的支持范围有所不同。若工具报告竞争,应先检查共享变量的读写是否有统一的同步方式,不要简单屏蔽报告。性能测试也应记录单线程与多线程下的运行时间,使用多个输入规模重复测试,并尽量避免让其他高负载进程影响结果。
十、把第一份作品整理成可复用练习
第一份多线程程序不必追求功能庞大。可以从本文求和程序继续改造:让每个任务读取一段只读数据,返回局部结果,再由主线程合并;之后再尝试加入线程池,观察任务数量远大于线程数量时,固定工作线程如何复用。每次只引入一种新机制,并先写出共享状态、对象生命周期和线程退出路径,再动手改代码。
记住三个边界:线程共享进程内存,但共享不等于可以无同步地并发修改;传给线程的对象必须活到线程不再访问它;创建线程和等待线程都要检查返回值并考虑失败路径。掌握这三点,再配合有界并发和可重复的测试,就能从一个求和小程序开始,逐步写出更可靠的 Linux 多线程程序。




