linux多线程编程使用教程:新手完成第一张作品的方法

站长资讯1小时前发布 jdkjadmin
0 00
linux多线程编程使用教程:新手完成第一张作品的方法

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 多线程程序。

© 版权声明

相关文章