嵌入式开发学习:多线程
本文介绍了Linux多线程编程的核心概念与技术。
一、核心原理:进程 vs 线程(底层视角)
1、进程 (Process):
-
当我们在Ubuntu上运行一个程序时,操作系统会创建一个进程。
-
底层关键点:进程是资源分配的基本单位。操作系统会为它分配一个完全独立、受保护的虚拟地址空间(包括代码段、数据段、堆、栈)。它还拥有自己的一组文件描述符、PID(进程ID)等。
-
创建开销:创建进程(如使用
fork()系统调用)开销很大,因为它需要复制整个父进程的地址空间(即使有写时复制-Copy-on-Write优化)和内核数据结构。
2、线程 (Thread):
-
线程,有时在Linux中被称为轻量级进程 (Light-Weight Process, LWP),是CPU调度的基本单位。
-
底层关键点:一个进程可以包含多个线程。这些线程共享同一个进程的绝大部分资源:
-
共享:虚拟地址空间(代码段、数据段、全局变量、堆)。
-
共享:文件描述符、信号处理器等。
-
-
独有:每个线程有自己独有的栈 (Stack)、程序计数器 (Program Counter) 和一组 CPU 寄存器。
-
创建开销:创建线程(如使用
pthread_create())开销远小于创建进程,因为它不需要创建新的虚拟地址空间。在Linux内核中,pthread_create最终会调用clone()系统调用,通过特定标志告诉内核创建一个新任务,但共享父任务的内存空间。
总结:线程是运行在进程上下文中的“执行流”。多线程编程的最大威力在于共享内存(数据交换极快),而最大危险也在于共享内存(需要同步)。
二、创建与等待线程 (pthread_create, pthread_join)
这是最基本的操作:启动一个新线程,并等待它执行完毕。
-
pthread_create():用于创建一个新的线程。 -
pthread_join():用于阻塞主线程,直到目标线程执行完毕。这非常重要,否则main函数可能先于子线程退出,导致整个进程被终止。
1、代码示例 (example1.c):
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h> // 必须包含的头文件
#include <unistd.h> // for sleep()
// 线程要执行的函数
// 它的签名必须是 void* (void*)
void* thread_function(void* arg) {
long thread_id = (long)arg;
printf("线程 %ld 正在运行...\n", thread_id);
sleep(1); // 模拟工作
printf("线程 %ld 结束。\n", thread_id);
// 线程可以返回一个值 (void*)
return (void*)(thread_id * 10);
}
int main() {
pthread_t thread1_id, thread2_id; // 用于存储线程ID的变量
void* thread1_return;
void* thread2_return;
printf("主线程:将创建两个子线程。\n");
// 创建第一个线程
// 参数:
// 1. 线程ID的指针 (传出参数)
// 2. 线程属性 (NULL = 默认)
// 3. 线程开始执行的函数
// 4. 传递给该函数的参数
if (pthread_create(&thread1_id, NULL, thread_function, (void*)1) != 0) {
perror("pthread_create 失败");
return 1;
}
// 创建第二个线程
if (pthread_create(&thread2_id, NULL, thread_function, (void*)2) != 0) {
perror("pthread_create 失败");
return 1;
}
printf("主线程:等待子线程结束...\n");
// 等待第一个线程结束,并获取其返回值
// 参数:
// 1. 要等待的线程ID
// 2. 用于接收线程返回值的指针的地址 (二级指针)
if (pthread_join(thread1_id, &thread1_return) != 0) {
perror("pthread_join 失败");
return 1;
}
printf("主线程:线程 1 返回值为 %ld\n", (long)thread1_return);
// 等待第二个线程结束
if (pthread_join(thread2_id, &thread2_return) != 0) {
perror("pthread_join 失败");
return 1;
}
printf("主线程:线程 2 返回值为 %ld\n", (long)thread2_return);
printf("主线程:所有子线程已结束,主线程退出。\n");
return 0;
}
编译与运行 (Ubuntu 16.04): pthreads 不是C标准库的一部分,它是一个单独的库。在编译时,您必须使用 -lpthread (或 -pthread) 来链接它。
gcc example1.c -o example1 -lpthread
./example1
预期输出:
主线程:将创建两个子线程。
主线程:等待子线程结束...
线程 1 正在运行...
线程 2 正在运行...
(等待1秒)
线程 1 结束。
主线程:线程 1 返回值为 10
(再等待1秒,因为线程2也在运行)
线程 2 结束。
主线程:线程 2 返回值为 20
主线程:所有子线程已结束,主线程退出。
(注意:线程1和线程2的 "正在运行" 顺序可能颠倒,这取决于OS调度器)
三、竞态条件 (Race Condition) - 问题的根源
现在我们来看看共享内存的危险。我们将创建10个线程,每个线程都对一个全局变量执行100万次加法。
1、代码示例 (example2.c):
#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 10
#define INCREMENTS 1000000
// 这是一个全局变量,被所有线程共享
long long global_counter = 0;
void* thread_function(void* arg) {
for (int i = 0; i < INCREMENTS; i++) {
// 危险操作!
global_counter++;
}
return NULL;
}
int main() {
pthread_t threads[NUM_THREADS];
// 创建10个线程
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, thread_function, NULL);
}
// 等待所有线程结束
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
printf("预期结果: %lld\n", (long long)NUM_THREADS * INCREMENTS);
printf("实际结果: %lld\n", global_counter);
return 0;
}
编译与运行:
gcc example2.c -o example2 -lpthread
./example2
预期输出 (某一次运行):
预期结果: 10000000
实际结果: 2134587
(注意:实际结果几乎总是不等于预期结果,并且每次运行都可能不同)
2、底层原理解析
global_counter++ 看起来是一行代码,但在CPU层面,它不是原子操作。它至少包含三个步骤:
-
Read:从内存中读取
global_counter的值到CPU寄存器 (如eax)。 -
Modify:将寄存器
eax的值加 1。 -
Write:将寄存器
eax中的新值写回内存中的global_counter。
竞态条件发生过程 (假设 global_counter 当前为 100):
-
线程A:执行 Read,将 100 读入它的寄存器。
-
<-- 此时发生上下文切换 (Context Switch),OS调度器暂停线程A,开始运行线程B。>
-
线程B:执行 Read,从内存读取
global_counter(值仍为 100)。 -
线程B:执行 Modify,其寄存器变为 101。
-
线程B:执行 Write,将 101 写回内存。
global_counter现在是 101。 -
<-- 此时发生上下文切换,线程A恢复运行。>
-
线程A:(它不知道内存已被更改) 执行 Modify,它在步骤1中读到的值是100,所以它的寄存器变为 101。
-
线程A:执行 Write,将 101 写回内存。
global_counter仍然是 101。
结果:两个线程都执行了 ++ 操作,但计数器只增加了1。一次递增丢失了。
四、使用互斥锁 (Mutex) 解决竞态条件
为了解决这个问题,我们需要一种机制来确保 "Read-Modify-Write" 这三个步骤作为一个原子单元执行,不被其他线程打断。这就是互斥锁 (Mutual Exclusion, Mutex)。
-
pthread_mutex_t:互斥锁变量。 -
pthread_mutex_init():初始化锁。 -
pthread_mutex_lock():加锁。如果锁已被其他线程持有,此函数会阻塞 (使当前线程睡眠),直到锁被释放。 -
pthread_mutex_unlock():解锁。 -
pthread_mutex_destroy():销毁锁。
1、代码示例 (example3.c) - 修复 example2:
#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 10
#define INCREMENTS 1000000
long long global_counter = 0;
// 1. 声明一个互斥锁
pthread_mutex_t counter_mutex;
void* thread_function(void* arg) {
for (int i = 0; i < INCREMENTS; i++) {
// 3. 在访问共享资源前加锁
pthread_mutex_lock(&counter_mutex);
// --- 临界区 (Critical Section) 开始 ---
// 在任何时刻,只有一个线程能执行这里的代码
global_counter++;
// --- 临界区 (Critical Section) 结束 ---
// 4. 访问完毕后解锁
pthread_mutex_unlock(&counter_mutex);
}
return NULL;
}
int main() {
pthread_t threads[NUM_THREADS];
// 2. 初始化互斥锁
if (pthread_mutex_init(&counter_mutex, NULL) != 0) {
perror("Mutex初始化失败");
return 1;
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, thread_function, NULL);
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
// 5. 销毁互斥锁
pthread_mutex_destroy(&counter_mutex);
printf("预期结果: %lld\n", (long long)NUM_THREADS * INCREMENTS);
printf("实际结果: %lld\n", global_counter);
return 0;
}
编译与运行:
gcc example3.c -o example3 -lpthread
./example3
预期输出:
预期结果: 10000000
实际结果: 10000000
(注意:这次结果总是正确的,但程序运行时间会比 example2 长,因为线程需要排队等待锁。)
2、底层原理解析
pthread_mutex_lock() 通常依赖于CPU提供的原子指令(例如 x86 上的 CMPXCHG - 比较并交换)。
-
尝试加锁 (非竞争情况):线程A调用
lock。它使用原子指令检查锁变量是否为 "unlocked"。如果是,它将其原子地设置为 "locked" 并立即返回。这个过程非常快。 -
尝试加锁 (竞争情况):线程B调用
lock。它原子地检查锁变量,发现是 "locked"。 -
阻塞:线程B不能继续。它会调用内核(如
futex- 快速用户空间互斥体),告诉内核:"请将我(线程B)放入此锁的等待队列,并让我睡眠 (Blocked/Sleeping 状态)。" -
调度:OS调度器会选择另一个处于 "Ready" 状态的线程(可能是线程C,或线程A)来运行。线程B此时不消耗任何CPU。
-
解锁:当线程A完成临界区代码后,它调用
unlock。 -
唤醒:
unlock将锁变量原子地设为 "unlocked",并通知内核:"如果这个锁的等待队列上有线程,请唤醒一个(或多个)。" -
重新调度:内核将线程B从 "Sleeping" 状态改回 "Ready" 状态。在未来的某个时刻,调度器会再次选择线程B运行,它将从
lock函数中返回(此时它已成功持有了锁)。
五、用条件变量 (Condition Variable) 实现线程协作
互斥锁解决了 "互斥" 问题,但没有解决 "同步" 或 "协作" 问题。例如:一个线程(生产者)生产数据,另一个线程(消费者)消费数据。如果缓冲区是空的,消费者必须等待,直到生产者放入了数据。
-
pthread_cond_t:条件变量。 -
pthread_cond_init():初始化。 -
pthread_cond_wait(cond, mutex):这是最关键的函数。 -
pthread_cond_signal(cond):唤醒至少一个正在等待cond的线程。 -
pthread_cond_broadcast(cond):唤醒所有正在等待cond的线程。
1、底层原理解析
pthread_cond_wait(cond, mutex) 必须与一个互斥锁配合使用。当调用它时,它会原子地执行以下三步:
-
解锁 传入的
mutex。 -
阻塞 (使当前线程睡眠),等待
cond被signal。 -
(当被唤醒后)重新加锁
mutex,然后才返回。
为什么必须这么复杂?
-
第1步 (解锁):如果不解锁,生产者线程将永远无法获得锁来添加数据和调用
signal,导致死锁 (Deadlock)。 -
第3步 (重锁):确保线程被唤醒后,能安全地访问(或检查)共享数据,防止竞态条件。
2、代码示例 (example4.c) - 简单的生产者-消费者模型:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 共享资源
int shared_data = 0;
int data_ready = 0; // 标志位
// 协作所需的锁和条件变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 静态初始化
// 消费者线程
void* consumer(void* arg) {
printf("消费者:启动,等待数据...\n");
// 必须先加锁才能调用 cond_wait
pthread_mutex_lock(&mutex);
// *** 关键:使用 while 循环检查条件 ***
// (防止 "虚假唤醒" - Spurious Wakeups)
while (data_ready == 0) {
printf("消费者:数据未就绪,调用 cond_wait 进入睡眠...\n");
// 1. 解锁
// 2. 睡眠
// 3. (被唤醒后) 重新加锁
pthread_cond_wait(&cond, &mutex);
printf("消费者:被唤醒,重新获得锁,检查数据...\n");
}
// 此时,我们 100% 确定 data_ready == 1 并且我们持有锁
printf("消费者:处理数据 -> %d\n", shared_data);
// 释放锁
pthread_mutex_unlock(&mutex);
return NULL;
}
// 生产者线程
void* producer(void* arg) {
printf("生产者:启动,准备生产数据...\n");
sleep(2); // 模拟生产耗时
// 加锁以保护共享数据
pthread_mutex_lock(&mutex);
printf("生产者:数据生产完毕。\n");
shared_data = 42; // 放入数据
data_ready = 1; // 设置标志位
// 唤醒一个正在等待 cond 的线程 (消费者)
printf("生产者:发送 signal 唤醒消费者...\n");
pthread_cond_signal(&cond);
// 释放锁
pthread_mutex_unlock(&mutex);
return NULL;
}
int main() {
pthread_t prod_tid, cons_tid;
pthread_create(&cons_tid, NULL, consumer, NULL);
// 稍微等一下,确保消费者先运行并等待
usleep(100 * 1000);
pthread_create(&prod_tid, NULL, producer, NULL);
pthread_join(prod_tid, NULL);
pthread_join(cons_tid, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
printf("主线程:生产者和消费者都已结束。\n");
return 0;
}
编译与运行:
gcc example4.c -o example4 -lpthread
./example4
预期输出:
消费者:启动,等待数据...
消费者:数据未就绪,调用 cond_wait 进入睡眠...
生产者:启动,准备生产数据...
(等待 2 秒)
生产者:数据生产完毕。
生产者:发送 signal 唤醒消费者...
消费者:被唤醒,重新获得锁,检查数据...
消费者:处理数据 -> 42
主线程:生产者和消费者都已结束。
更多推荐
所有评论(0)