ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Linux C语言多线程编程入门:从pthread基础到竞态条件与同步机制

Linux C语言多线程编程入门:从pthread基础到竞态条件与同步机制 1. 从“单打独斗”到“协同作战”为什么我们需要线程如果你写过C语言程序尤其是处理过一些需要等待用户输入、读写文件或者进行网络通信的任务你大概率遇到过这样的场景程序卡在某个耗时的操作上整个界面“冻住”了用户只能干等着。这就是典型的单线程程序它像一个只能一心一意的工人同一时间只能做一件事。在Linux环境下POSIX线程通常简称为pthreads就是解决这个问题的利器。它允许一个进程“分身”出多个执行流这些执行流共享进程的大部分资源如内存空间、打开的文件描述符但各自拥有独立的栈和程序计数器。这就像把一个单核CPU的“单线程”思维升级到了多核时代的“并行”思维。我最初接触线程是为了写一个简单的网络服务器主线程负责监听新的连接而每来一个新连接就创建一个新线程去处理这个连接的读写。这样服务器就能同时服务多个客户端而不会因为一个客户端的慢速操作而阻塞所有人。这就是线程最直观的价值提升程序的响应能力和吞吐量。但线程带来的不仅仅是性能提升更是一种编程范式的转变。它把“顺序执行”的逻辑变成了“并发协作”的逻辑。理解线程是理解现代高性能、高并发软件从Nginx、Redis到你的手机App基础架构的第一步。然而与强大的能力相伴的是更高的复杂性比如数据竞争、死锁这些“坑”也正是我们需要系统学习它的原因。接下来的内容我会结合代码和场景带你从环境准备到核心概念一步步拆解POSIX线程。2. 搭建你的第一个多线程实验环境在动手写代码之前确保你的开发环境就绪是第一步。对于Linux下的C语言线程编程你几乎不需要安装额外的软件包因为pthread库是POSIX标准的一部分通常作为GlibcGNU C库的一部分提供。但为了编译和链接我们需要明确一些细节。2.1 编译器与链接选项最常用的编译器是GCC。编译一个使用了pthreads的程序关键不在于include哪个特殊的头文件虽然头文件是必须的而在于链接时需要加上-pthread选项。这里有一个非常重要的区别-pthread和-lpthread。虽然它们经常被混用并且在许多系统上效果相同但最佳实践是使用-pthread。这个选项是一个“编译器与链接器综合指令”它做了两件事确保预处理器定义了必要的宏如_REENTRANT告诉C库这个程序是多线程的C库会因此使用线程安全的函数版本。在链接阶段自动链接正确的线程库。而-lpthread仅仅是一个链接器选项告诉链接器去查找名为pthread的库文件。在某些平台或特定配置下仅使用-lpthread可能无法正确设置那些预处理宏。所以记住这个命令格式gcc -o my_program my_program.c -pthread2.2 验证环境与第一个程序让我们写一个最简单的程序来验证环境。这个程序将创建两个线程各自打印一条消息。#include stdio.h #include stdlib.h #include pthread.h #include unistd.h // 用于 sleep 函数 // 线程函数必须符合这个签名void *function_name(void *arg) void* print_message(void* arg) { char* message (char*)arg; printf(Thread says: %s\n, message); // 线程函数结束时可以返回一个值。这里我们返回NULL。 return NULL; } int main() { pthread_t thread1, thread2; // pthread_t 是线程标识符类型 char* message1 Hello from Thread 1; char* message2 Hello from Thread 2; // 创建线程 // 参数1: 指向 pthread_t 的指针用于存储新线程的ID。 // 参数2: 线程属性NULL表示使用默认属性。 // 参数3: 线程要执行的函数指针。 // 参数4: 传递给线程函数的参数。 int ret1 pthread_create(thread1, NULL, print_message, (void*)message1); int ret2 pthread_create(thread2, NULL, print_message, (void*)message2); // pthread_create 成功时返回0失败时返回错误码。 if (ret1 ! 0 || ret2 ! 0) { fprintf(stderr, Failed to create thread(s).\n); exit(EXIT_FAILURE); } printf(Main thread: Both threads created.\n); // 等待线程结束。如果不等待主线程可能先退出导致整个进程结束子线程被强制终止。 pthread_join(thread1, NULL); pthread_join(thread2, NULL); printf(Main thread: All threads have finished.\n); return 0; }将这段代码保存为thread_basic.c然后编译运行gcc -o thread_basic thread_basic.c -pthread ./thread_basic你可能会看到类似这样的输出但顺序可能每次运行都不一样Main thread: Both threads created. Thread says: Hello from Thread 2 Thread says: Hello from Thread 1 Main thread: All threads have finished.或者Thread says: Hello from Thread 1 Main thread: Both threads created. Thread says: Hello from Thread 2 Main thread: All threads have finished.这个“顺序不确定”正是多线程编程的第一个核心特征线程的执行顺序由操作系统调度器决定程序员无法预设。主线程的printf和两个子线程的printf谁先执行是不确定的。pthread_join的作用是阻塞主线程直到指定的线程执行完毕这确保了主线程不会提前退出。这是一个非常重要的同步机制。3. 线程的创建、管理与基本生命周期理解了第一个程序后我们来系统性地看看线程从生到死的几个关键操作。3.1 线程创建pthread_create它的函数原型是int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);thread: 输出参数。成功创建后这里会填入新线程的ID。attr: 线程属性。可以设置线程的栈大小、调度策略、分离状态等。99%的初学者场景下用NULL指定默认属性就足够了。当我们需要调整栈空间比如处理递归很深的函数或创建“分离线程”时才会用到它。start_routine: 线程的入口函数。它必须是一个返回void*且接受一个void*参数的函数。这个设计非常巧妙void*是“万能指针”意味着你可以传递任何类型的单个参数给线程也可以通过返回一个指针来传递结果。arg: 传递给start_routine的参数。同样因为类型是void*你可以传递一个整型需要强制转换、一个结构体指针、甚至NULL。注意这里有一个新手常犯的错误。如果你直接传递一个局部变量的地址给线程而该变量所在的作用域比如函数很快结束了变量被销毁那么线程再去访问那个地址就是“野指针”会导致未定义行为通常是段错误。正确的做法是传递堆上分配的内存malloc或者确保原变量的生命周期覆盖线程的整个执行期。3.2 线程终止多种方式线程结束其执行流的方式有好几种从线程函数中return。这是最干净的方式返回的值可以被其他线程通过pthread_join获取。调用pthread_exit(void *retval)。可以在线程函数的任何地方调用效果类似于returnretval同样是退出状态。被同一进程中的其他线程pthread_cancel取消。这类似于向线程发送一个“终止请求”线程是否立即终止、以及在何处终止取决于其取消状态和取消类型设置这是一个相对高级且需要谨慎使用的特性。进程终止。如果进程因为任何原因如主线程return、调用了exit()结束那么其所有线程都会立即被终止。3.3 线程连接与分离pthread_join与pthread_detach创建线程后你必须决定如何“处理”它结束后的资源。pthread_join(pthread_t thread, void **retval): 这是一个阻塞调用。调用它的线程通常是主线程会一直等待直到thread指定的线程终止。如果retval不是NULL线程的退出状态即return或pthread_exit的值会被存放在retval指向的位置。pthread_join还有一个关键作用它负责回收已终止线程的资源主要是栈空间。如果一个线程没有被join也没有被detach那么它终止后会变成一种“僵尸线程”其资源无法被回收造成资源泄漏。pthread_detach(pthread_t thread): 这个调用告诉系统“我不关心这个线程的退出状态也不打算join它你在线程结束后自动回收它的资源吧。”线程一旦被分离就不能再被join了。这对于那些“干完活就消失不需要汇报结果”的后台任务线程非常有用。你可以在创建线程后由其他线程调用detach也可以在线程属性中设置PTHREAD_CREATE_DETACHED来直接创建分离线程。一个重要的经验对于你创建的每一个线程心里必须明确它的“归宿”——要么被join要么被detach。这是避免资源泄漏的铁律。我个人的习惯是除非明确知道这个线程是纯粹的后台任务否则先创建可连接线程并在合适的地方join它这样更容易管理和调试。4. 共享数据与竞态条件多线程的第一个大坑线程共享进程的内存空间这带来了便利也带来了麻烦。最大的麻烦就是竞态条件。让我们看一个经典的例子两个线程同时对一个全局变量进行递增操作。#include stdio.h #include pthread.h int counter 0; // 共享的全局变量 void* increment(void* arg) { int loops *(int*)arg; for (int i 0; i loops; i) { counter; // 这行代码是“危险”的 } return NULL; } int main() { pthread_t t1, t2; int loops 1000000; // 每个线程递增100万次 pthread_create(t1, NULL, increment, loops); pthread_create(t2, NULL, increment, loops); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(Expected value: %d\n, 2 * loops); printf(Actual value: %d\n, counter); return 0; }编译运行这个程序你很可能得不到预期的2000000。我多次运行的结果是1999876, 1999952, 1999987... 每次都不一样且总是小于200万。为什么因为counter这行看似简单的C语句在底层通常不是原子操作。它至少对应三条机器指令从内存将counter的值加载到CPU寄存器。将寄存器中的值加1。将新值存回内存中的counter。操作系统可能在任何时刻暂停一个线程切换到另一个线程。考虑以下交错执行的情况初始counter 0。线程A执行第1步将0读入寄存器。线程A被操作系统挂起线程B开始执行。线程B完整地执行了第1、2、3步将内存中的counter从0变成了1。线程B被挂起线程A恢复执行。线程A接着执行它自己的第2、3步。此时它寄存器里的值还是0加1后变成1然后写回内存。最终内存中的counter是1而不是正确的2。两次递增操作因为不当的交叉执行只让结果增加了1。这就是竞态条件。要解决这个问题我们必须引入同步机制确保某一时刻只有一个线程能执行counter这段“临界区”代码。5. 互斥锁保护临界区的卫士POSIX线程中最基本、最常用的同步工具就是互斥锁。你可以把它想象成一个房间的钥匙一次只允许一个线程持有钥匙进入房间临界区其他线程必须在门口等待。5.1 互斥锁的基本使用#include stdio.h #include pthread.h int counter 0; pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; // 静态初始化互斥锁 void* increment_with_mutex(void* arg) { int loops *(int*)arg; for (int i 0; i loops; i) { pthread_mutex_lock(mutex); // 加锁进入临界区 counter; pthread_mutex_unlock(mutex); // 解锁离开临界区 } return NULL; } int main() { pthread_t t1, t2; int loops 1000000; pthread_create(t1, NULL, increment_with_mutex, loops); pthread_create(t2, NULL, increment_with_mutex, loops); pthread_join(t1, NULL); pthread_join(t2, NULL); // 销毁互斥锁对于静态初始化的锁在进程结束时系统会自动清理但显式销毁是好习惯 pthread_mutex_destroy(mutex); printf(Expected value: %d\n, 2 * loops); printf(Actual value: %d\n, counter); // 现在结果正确了一定是2000000 return 0; }现在无论运行多少次结果都是正确的2000000。pthread_mutex_lock和pthread_mutex_unlock像一对括号把对共享变量counter的访问保护了起来。5.2 互斥锁的初始化与销毁互斥锁有两种初始化方式静态初始化使用宏PTHREAD_MUTEX_INITIALIZER。如上例所示它简单但只能用于全局或静态变量且属性为默认。动态初始化使用函数pthread_mutex_init。pthread_mutex_t mutex; pthread_mutexattr_t attr; // 属性对象可用于设置锁类型如递归锁 pthread_mutexattr_init(attr); // 可以在这里设置属性例如 pthread_mutexattr_settype(attr, PTHREAD_MUTEX_RECURSIVE); pthread_mutex_init(mutex, attr); // 第二个参数为NULL则使用默认属性 pthread_mutexattr_destroy(attr); // 初始化后属性对象可销毁动态初始化更灵活可以在运行时进行也可以设置不同的锁属性。无论哪种方式使用完毕后都应调用pthread_mutex_destroy来销毁锁释放可能关联的资源。5.3 使用互斥锁的注意事项与死锁互斥锁用起来简单但陷阱也不少。锁的粒度锁保护的范围太大比如锁住整个循环会严重降低并发性能因为大部分时间线程都在串行等待。锁的范围太小又可能保护不周依然产生竞态。需要仔细设计临界区只保护必须共享的数据操作。死锁这是使用锁时最危险的问题。典型的死锁场景是“哲学家就餐问题”的简化版两个线程各自持有一把锁同时去请求对方持有的锁就会陷入互相等待的僵局。// 线程A pthread_mutex_lock(mutex1); // ... 一些操作 ... pthread_mutex_lock(mutex2); // 如果此时线程B已经锁住了mutex2则A在此等待 // ... // 线程B pthread_mutex_lock(mutex2); // ... 一些操作 ... pthread_mutex_lock(mutex1); // 如果此时线程A已经锁住了mutex1则B在此等待 // ... 死锁发生避免死锁的黄金法则固定顺序加锁如果多个线程都需要获取多把锁约定一个全局的加锁顺序例如总是先锁mutex1再锁mutex2所有线程都遵守这个顺序。尝试加锁使用pthread_mutex_trylock。这个函数不会阻塞如果锁不可用就立即返回错误。线程可以据此释放已持有的锁过段时间再重试从而打破死锁循环。但这通常使逻辑变得复杂。保持锁的时间尽可能短尽快完成临界区操作并释放锁减少其他线程等待的时间也降低了死锁发生的窗口期。在我早期写一个文件处理工具时就踩过死锁的坑。两个模块各自持有一把锁然后互相调用对方提供的函数而对方函数内部又试图获取调用者已经持有的锁。排查了半天才发现是循环依赖导致的死锁。最后的解决方案是重构代码将两个模块的公共操作提取到第三个函数中并由这个第三方函数以固定顺序统一加锁。6. 条件变量让线程学会“等待”与“通知”互斥锁解决了“互斥访问”的问题但线程间协作常常需要更复杂的机制一个线程需要等待某个条件成立例如任务队列不为空才能继续执行而另一个线程在使条件成立后例如向队列放入任务需要通知等待的线程。这就是条件变量的用武之地。条件变量总是与一个互斥锁配合使用。其工作模式可以概括为“在持有锁的情况下检查条件如果条件不满足则释放锁并进入等待当被唤醒后重新获取锁并再次检查条件。”6.1 生产者-消费者模型示例这是理解条件变量的最佳范例。我们实现一个简单的单生产者、单消费者的缓冲队列。#include stdio.h #include stdlib.h #include pthread.h #include unistd.h #define BUFFER_SIZE 5 int buffer[BUFFER_SIZE]; int count 0; // 缓冲区中当前物品数量 int in 0; // 生产者放入位置 int out 0; // 消费者取出位置 pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond_producer PTHREAD_COND_INITIALIZER; // 生产者条件缓冲区未满 pthread_cond_t cond_consumer PTHREAD_COND_INITIALIZER; // 消费者条件缓冲区非空 void* producer(void* arg) { int item 0; while (1) { pthread_mutex_lock(mutex); // 等待条件缓冲区未满。注意这是一个while循环而不是if。 while (count BUFFER_SIZE) { // pthread_cond_wait 会做三件事 // 1. 释放互斥锁 mutex。 // 2. 阻塞当前线程等待在条件变量 cond_producer 上。 // 3. 当被唤醒时在返回前重新获取互斥锁 mutex。 pthread_cond_wait(cond_producer, mutex); // 被唤醒后需要重新检查条件因为可能有多个生产者被唤醒或者条件被“虚假唤醒”。 } // 生产物品 buffer[in] item; printf(Produced: %d at index %d\n, item, in); in (in 1) % BUFFER_SIZE; count; item; // 通知可能正在等待的消费者现在缓冲区非空了。 pthread_cond_signal(cond_consumer); pthread_mutex_unlock(mutex); // 模拟生产耗时 sleep(1); } return NULL; } void* consumer(void* arg) { while (1) { pthread_mutex_lock(mutex); // 等待条件缓冲区非空 while (count 0) { pthread_cond_wait(cond_consumer, mutex); } // 消费物品 int item buffer[out]; printf(Consumed: %d from index %d\n, item, out); out (out 1) % BUFFER_SIZE; count--; // 通知可能正在等待的生产者现在缓冲区未满了。 pthread_cond_signal(cond_producer); pthread_mutex_unlock(mutex); // 模拟消费耗时 sleep(2); } return NULL; } int main() { pthread_t prod, cons; pthread_create(prod, NULL, producer, NULL); pthread_create(cons, NULL, consumer, NULL); // 让程序运行一段时间 sleep(10); // 在实际程序中应该有更优雅的终止机制。这里我们简单粗暴地结束。 printf(Main thread exiting. (In a real program, cancel threads properly)\n); // 注意这里没有进行线程取消和清理仅作示例。 return 0; }运行这个程序你会看到生产者和消费者有条不紊地协作生产者填满缓冲区后等待消费者取走物品后通知生产者消费者清空缓冲区后等待生产者放入物品后通知消费者。6.2 条件变量的关键细节为什么用while检查条件而不是if这是条件变量使用中最容易出错的地方。pthread_cond_wait可能因为“虚假唤醒”而返回即没有其他线程调用signal或broadcast等待的线程也可能被唤醒。这是某些系统实现为了性能而允许的行为。因此被唤醒后必须重新检查条件是否真正满足。使用while循环是确保这一点的标准模式。pthread_cond_signalvspthread_cond_broadcastsignal唤醒至少一个正在该条件变量上等待的线程。如果多个线程在等待具体唤醒哪一个是不确定的。适用于“单消费者”或资源唯一的情况。broadcast唤醒所有正在该条件变量上等待的线程。它们会竞争互斥锁然后依次检查条件。适用于“多消费者”或资源有多个的情况比如线程池有多个工作线程等待任务。使用broadcast通常更安全但可能引发“惊群效应”降低性能。条件变量不存储状态条件变量本身并不记录“条件是否成立”这个信息。它只是一个让线程排队等待和接收通知的机制。条件的真假必须由程序员用一个共享变量如上例中的count来维护并且这个共享变量的访问必须用互斥锁保护。掌握互斥锁和条件变量你就已经能够解决绝大多数Linux C语言下的线程同步问题了。它们是多线程编程的基石理解其原理和使用模式对于编写正确、高效的并发程序至关重要。在后续的学习中你会遇到读写锁、信号量、屏障等更高级的同步原语但它们都是建立在互斥和条件变量这两个核心概念之上的变体或封装。
返回列表