C 多线程编程入门:从线程创建到同步控制

运营教程14小时前发布 jdkjadmin
0 00
C 多线程编程入门:从线程创建到同步控制

C 多线程编程入门:从线程创建到同步控制

多线程允许一个进程中的多个执行流并发工作,共享同一地址空间。它适用于 I/O 等待与计算重叠、把可拆分的计算任务分配给多个核心,以及让服务程序同时处理多个请求等场景。但线程并不会自动让程序更快:任务若很小、线程创建和同步开销占比过高,或者工作负载受内存带宽限制,多线程反而可能增加延迟和复杂度。

本文以 POSIX 线程(通常称为 pthread)为例,介绍线程的创建与生命周期,以及互斥锁、条件变量和原子操作的基本用法。示例面向常见的 Linux、macOS 等 POSIX 环境,不是 ISO C 标准库的可移植线程接口。代码中的 pthread 调用需要包含相应头文件,并在支持 pthread 的系统上编译运行。

一、线程共享什么,边界在哪里

同一进程中的线程共享进程地址空间,包括全局变量、静态变量、堆上分配的内存,以及进程打开的文件描述符等资源。每个线程则有自己的执行位置、寄存器状态和线程栈。正因为线程共享内存,线程之间传递数据通常不必像进程间通信那样复制整块消息;也正因为共享,多个线程同时读写同一数据时,必须明确同步规则。

若两个线程没有建立适当的同步关系,却至少有一个线程对同一内存位置进行写入,就可能发生数据竞争。在 C 语言中,数据竞争会导致未定义行为;这不只是“偶尔读到旧值”,编译器还可能基于“程序没有数据竞争”的前提优化代码。不能依赖某次测试中看起来正常的结果。

线程也不是安全边界。进程内线程通常共享权限、文件描述符和地址空间;一个线程发生内存破坏,可能影响其他线程。若目标是隔离不可信代码、限制权限或降低故障影响,应考虑进程隔离、操作系统权限控制等机制,而不是把不同任务放进同一进程的不同线程。

二、创建线程与传递参数

pthread 线程入口函数的签名为 void *(*)(void *):接收一个 void * 参数,并返回一个 void *。pthread_create 成功时创建线程,线程可能在调用者继续执行之前就开始运行,因此传给它的数据从创建时起就必须有效。

#include <pthread.h>
#include <stdio.h>
#include <string.h>

struct worker_arg {
    int id;
    const char *message;
};

static void *worker(void *opaque)
{
    struct worker_arg *arg = opaque;

    printf("worker %d: %s\n", arg->id, arg->message);
    return NULL;
}

int main(void)
{
    pthread_t thread;
    struct worker_arg arg = { 1, "hello from a thread" };

    int rc = pthread_create(&thread, NULL, worker, &arg);
    if (rc != 0) {
        fprintf(stderr, "pthread_create: %s\n", strerror(rc));
        return 1;
    }

    rc = pthread_join(thread, NULL);
    if (rc != 0) {
        fprintf(stderr, "pthread_join: %s\n", strerror(rc));
        return 1;
    }

    return 0;
}

在支持 pthread 的 Linux 系统上,可将示例保存为 thread_basic.c,用下面的命令编译:

cc -std=c11 -Wall -Wextra -Wpedantic -pthread thread_basic.c -o thread_basic

参数 NULL 表示使用默认线程属性。创建失败时,pthread_create 返回非零错误码,不应通过检查 errno 来判断;pthread 函数通常直接返回错误码,因此示例用 strerror(rc) 格式化。实际项目还应决定创建失败后的清理和降级策略。

示例传递的是主线程栈上的 arg。这在这里是安全的,因为主线程在 pthread_join 返回前一直没有离开其作用域,而工作线程也在 join 前结束。若主线程先返回、离开作用域或复用这块存储,工作线程仍在读取参数,就会访问失效或被覆盖的数据。

常见的错误是循环中传递循环变量的地址:多个工作线程可能读到同一变量,而且主线程可能在它们读取之前就修改了变量。应为每个线程提供独立且生命周期足够长的参数对象,例如事先分配数组、使用堆分配并明确由谁释放,或采用有锁保护的任务队列。若在线程内部使用 malloc 分配参数,要约定由工作线程还是管理线程释放,避免泄漏或重复释放。

三、等待线程结束与线程生命周期

pthread_create 返回后,创建者和新线程会并发运行。若创建者需要等待工作完成并取得返回值,应使用 pthread_join。它会等待指定的可连接线程结束,并可通过第三个参数接收线程入口函数的返回值。这里的返回值只是一个指针;若返回指向线程栈上局部变量的地址,线程结束后该地址就不再有效。需要传回数据时,可以返回预先约定的整数编码,或返回由调用者负责释放的堆对象。

一个线程应当被 join,或者被 detach。joinable 线程结束后,其部分资源仍需由程序回收;对它调用 pthread_join 可以完成回收。分离线程则不能再被 join,线程结束时系统会回收它自身的可回收资源。可以在创建时通过线程属性将线程设为分离状态,也可以在线程句柄上调用 pthread_detach。不要对同一个线程重复 join,也不要同时让多个线程争抢 join 同一线程;这类用法不应作为程序设计依据。

分离不等于“自动管理所有资源”:线程仍可能持有互斥锁、文件描述符或应用层分配的内存,这些资源仍须由程序按规则释放。若主线程需要知道分离线程何时完成,应另行设计通知机制,而不是试图 join 它。

程序结束时主线程从 main 返回,会导致进程终止,其他线程也不会因为自己尚未完成就继续运行。若要求工作线程完成,应显式 join,或设计可靠的关闭与等待流程。不要把“创建线程成功”误当成“工作已完成”。

四、互斥锁:保护共享状态

互斥锁(mutex)用于让多个线程排他地访问一段共享状态。线程进入临界区前加锁,完成相关读写后解锁。锁应保护的是一条完整的数据不变量,而不只是某一次赋值;如果更新一个对象需要同步修改多个字段,就应在同一套同步规则下完成。

下面是一个可运行的计数器示例。多个线程各自增加计数器,最终由主线程等待它们结束,再读取结果。计数器的读写都由同一把锁保护。

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

enum { THREAD_COUNT = 4, INCREMENTS = 100000 };

static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static long counter = 0;

static void *increment_worker(void *unused)
{
    (void)unused;

    for (int i = 0; i < INCREMENTS; ++i) {
        int rc = pthread_mutex_lock(&mutex);
        if (rc != 0) {
            fprintf(stderr, "pthread_mutex_lock: %s\n", strerror(rc));
            return NULL;
        }

        ++counter;

        rc = pthread_mutex_unlock(&mutex);
        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_worker, NULL);
        if (rc != 0) {
            fprintf(stderr, "pthread_create: %s\n", strerror(rc));
            break;
        }
        ++created;
    }

    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));
            return 1;
        }
    }

    printf("counter = %ld, expected = %d\n",
           counter, created * INCREMENTS);

    int rc = pthread_mutex_destroy(&mutex);
    if (rc != 0) {
        fprintf(stderr, "pthread_mutex_destroy: %s\n", strerror(rc));
        return 1;
    }

    return 0;
}

保存为 counter.c,仍可用前面的编译选项构建。若创建某个线程失败,示例会等待已经创建成功的线程,再报告实际对应的预期计数。真实程序通常还需在部分初始化失败时逐项清理已经创建的锁、条件变量和线程资源。

互斥锁不要求一定在共享变量旁边声明,但应让保护关系清晰、可审查。所有访问同一受保护状态的代码路径都必须遵守同一规则;只在写入时加锁、无锁读取,仍可能构成数据竞争。也不要在持锁期间执行可能长时间阻塞的 I/O、等待用户输入,或调用会重入同一锁的函数,否则会降低并发性,甚至死锁。

死锁常见于以下情形:线程持有锁 A 时等待锁 B,另一个线程持有锁 B 时等待锁 A;线程对普通非递归互斥锁重复加锁;或者线程在持锁状态下等待一个必须由其他线程完成、但其他线程又需要同一把锁才能推进的操作。多锁场景应规定统一加锁顺序,并尽量缩短持锁范围。若某次加锁成功后程序有多个退出路径,要确保每条路径最终都会解锁;可通过集中清理路径、封装锁管理函数或清晰的状态标记降低遗漏概率。

五、条件变量:等待某个状态,而不是等待时间

条件变量让线程在某个共享条件尚未成立时睡眠,并由其他线程在条件可能改变时发出通知。条件变量必须与互斥锁配合:等待者在持锁时检查条件,并调用 pthread_cond_wait;该函数会原子地释放互斥锁并进入等待,收到通知后重新取得互斥锁再返回。

条件变量的通知本身不代表条件已经成立。线程可能在条件仍不满足时被唤醒,也可能多个等待者被唤醒后只有部分线程能取得任务。因此应使用 while 反复检查条件,而不是使用 if 检查一次就继续。下面示例演示一个生产者发布任务、消费者等待并取走任务的简单流程。

#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <time.h>

static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
static int ready = 0;
static int item = 0;

static void *consumer(void *unused)
{
    (void)unused;

    int rc = pthread_mutex_lock(&mutex);
    if (rc != 0) {
        fprintf(stderr, "lock: %s\n", strerror(rc));
        return NULL;
    }

    while (!ready) {
        rc = pthread_cond_wait(&cond, &mutex);
        if (rc != 0) {
            fprintf(stderr, "wait: %s\n", strerror(rc));
            pthread_mutex_unlock(&mutex);
            return NULL;
        }
    }

    int value = item;
    ready = 0;

    rc = pthread_mutex_unlock(&mutex);
    if (rc != 0) {
        fprintf(stderr, "unlock: %s\n", strerror(rc));
        return NULL;
    }

    printf("consumed: %d\n", value);
    return NULL;
}

int main(void)
{
    pthread_t thread;
    int rc = pthread_create(&thread, NULL, consumer, NULL);
    if (rc != 0) {
        fprintf(stderr, "create: %s\n", strerror(rc));
        return 1;
    }

    struct timespec delay = { .tv_sec = 0, .tv_nsec = 100000000 };
    (void)nanosleep(&delay, NULL);

    rc = pthread_mutex_lock(&mutex);
    if (rc != 0) {
        fprintf(stderr, "lock: %s\n", strerror(rc));
        return 1;
    }

    item = 42;
    ready = 1;
    rc = pthread_cond_signal(&cond);
    if (rc != 0) {
        fprintf(stderr, "signal: %s\n", strerror(rc));
        pthread_mutex_unlock(&mutex);
        return 1;
    }

    rc = pthread_mutex_unlock(&mutex);
    if (rc != 0) {
        fprintf(stderr, "unlock: %s\n", strerror(rc));
        return 1;
    }

    rc = pthread_join(thread, NULL);
    if (rc != 0) {
        fprintf(stderr, "join: %s\n", strerror(rc));
        return 1;
    }

    pthread_cond_destroy(&cond);
    pthread_mutex_destroy(&mutex);
    return 0;
}

这个示例用短暂休眠让消费者更可能先进入等待状态,但休眠不是同步手段,也不能保证线程调度顺序。正确性来自互斥锁保护的 ready 条件:如果生产者先设置状态,消费者随后加锁时会直接看到条件成立,不会永久等待通知。实际程序不应靠延时“解决竞态”;应让共享条件和等待逻辑本身正确。

pthread_cond_signal 至少唤醒一个等待者;如果有多个等待者且一次状态变化足以让多个线程继续工作,可考虑 pthread_cond_broadcast。无论用哪种通知,等待者都要重新检查共享条件。条件变量、互斥锁及其保护的数据应有明确的共同生命周期;销毁仍被线程使用或等待的同步对象是错误的。

六、原子操作适合什么情况

若共享状态只是简单计数、标志或指针交换,C11 原子类型可能比互斥锁更合适。例如,以下程序用原子计数器统计多个线程的工作次数:

#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>

enum { WORKERS = 4, LOOPS = 100000 };

static atomic_long total = 0;

static void *worker(void *unused)
{
    (void)unused;
    for (int i = 0; i < LOOPS; ++i) {
        atomic_fetch_add(&total, 1);
    }
    return NULL;
}

int main(void)
{
    pthread_t threads[WORKERS];

    for (int i = 0; i < WORKERS; ++i) {
        int rc = pthread_create(&threads[i], NULL, worker, NULL);
        if (rc != 0) {
            return 1;
        }
    }

    for (int i = 0; i < WORKERS; ++i) {
        int rc = pthread_join(threads[i], NULL);
        if (rc != 0) {
            return 1;
        }
    }

    printf("total = %ld\n", atomic_load(&total));
    return 0;
}

这里的原子加法避免了对计数器的非原子读改写竞争。原子操作并不自动保护由多个字段构成的不变量,也不自动让复杂的对象更新变成原子事务。内存序会影响不同原子操作之间的可见性和排序;初学时应优先使用默认的顺序一致语义,并在理解内存模型后再为性能选择其他内存序。不要仅因为某个操作写成了“原子”就推断整个算法安全。

七、创建数量、栈空间和系统资源

线程数没有一个适用于所有系统和程序的固定上限。创建是否成功受操作系统资源、进程和用户限制、地址空间、线程栈大小及系统负载等因素影响。pthread_create 失败时应检查返回码,并能处理部分线程已经创建成功的情况。不要假设机器核心数就是可创建线程数,也不要为了“充分利用 CPU”而无限制创建线程。

每个线程通常有独立栈,默认栈大小由实现、系统配置或线程属性决定。对线程属性设置栈大小时,具体可用范围依实现而异;POSIX 提供 PTHREAD_STACK_MIN 作为相关的最小值约束,但它不表示任意线程工作负载都只需要这么多栈空间。递归深度、局部数组和调用链都会影响栈需求。设置过小可能导致栈耗尽,过大则会增加虚拟地址空间或资源压力。需要调整时应查询目标平台文档、检查属性设置调用的返回值,并通过测试验证。

高并发服务器通常不会为每个很短的任务创建一个新线程,而会使用有上限的线程池:固定或可调数量的工作线程从任务队列中取工作,队列长度和拒绝策略也有明确限制。这样可以控制资源占用,避免突发请求导致线程数量快速膨胀。线程池本身仍需正确处理队列同步、退出通知和任务取消。

八、信号、取消与退出清理

信号(signal)不是线程同步原语。进程定向信号可能由某个未屏蔽该信号的线程接收;线程定向信号则有相应接口和规则。不要在异步信号处理函数中调用普通的 pthread 锁操作、分配内存或执行不保证异步信号安全的函数。常见做法是让信号处理函数只设置符合规则的标志,或使用专门的信号等待机制,由正常执行上下文处理后续工作。

pthread 支持取消线程,但取消不是通用的“强制停止”方案。取消类型、取消点、持有的锁和已分配资源都会影响结果;线程若在取消时未释放锁,其他线程可能永久受阻。若使用取消机制,应先设计清理处理程序和资源所有权,并明确使用场景。很多业务程序更适合采用合作式关闭:通过受锁保护的停止标志、条件变量或任务队列通知线程退出,再逐一 join。

线程还可能调用 pthread_exit 结束自身,但它不会替代主程序对线程生命周期的整体管理。无论线程以哪种方式结束,都要确保锁、内存、文件描述符及其他资源有清晰的清理责任。

九、常见错误检查清单

  • 共享变量无同步:多个线程并发读写普通变量,或者一边加锁写、一边无锁读。应为所有访问规定一致的锁或原子规则。
  • 参数或返回值失效:把局部变量地址传给尚未完成的线程,或返回线程栈上局部对象的地址。应保证对象生命周期覆盖所有访问,并明确释放责任。
  • 忘记等待或错误等待:joinable 线程既未 join 也未 detach,或对同一线程重复 join。应为每个线程选择明确的回收策略。
  • 把通知当成条件:收到条件变量通知就直接继续,或只用 if 检查条件。应在持锁状态下用 while 重新检查共享状态。
  • 锁范围过大或顺序不统一:在持锁期间做慢 I/O,或不同线程以相反顺序获取多把锁。应缩短临界区并规定全局锁顺序。
  • 忽略错误码:pthread 函数返回的错误码通常不是通过 errno 报告。应检查调用结果,并考虑初始化、创建、加锁和等待失败后的清理。
  • 并发访问标准 I/O 或其他库对象却无设计:不同库函数的线程安全承诺并不相同。即便单次输出不会破坏内部状态,也不代表多次输出会按预期顺序拼接。需要整体一致性时,应自行同步。

十、实践建议

从少量线程和简单共享状态开始,先画出数据由谁拥有、谁能读写、何时释放,再选择同步方式。对每个共享对象,记录保护它的锁、条件变量或原子规则;对每个线程,记录创建者、参数生命周期、结束通知和回收责任。线程数量应基于任务结构和测量结果决定,而不是凭直觉增加。

调试并发程序时,重复运行只能发现一部分问题,不能证明没有数据竞争。可以使用支持线程检测的工具辅助测试,例如在适用的平台上使用 ThreadSanitizer;也可以检查锁顺序、构造极端负载并测试创建失败和关闭流程。工具无法替代对数据所有权和同步关系的设计,但能帮助发现许多难以复现的错误。

入门阶段可以遵循一条简单原则:独立工作尽量使用线程局部数据;确实需要共享时,先用互斥锁建立正确性;只有在确认需要并理解其语义后,再采用原子操作或更复杂的无锁结构。多线程编程的重点不是尽可能并行,而是在明确的生命周期与同步规则下,让并发工作保持可预测、可维护和可验证。

© 版权声明

相关文章