23.1 线程
C11 标准引入了线程支持库(<threads.h>),提供了标准的多线程编程接口。
1. threads.h
1.1 线程类型和函数
主要类型:
thrd_t: 线程标识符mtx_t: 互斥锁cnd_t: 条件变量tss_t: 线程局部存储once_flag: 一次性初始化标记
主要函数:
thrd_create(): 创建线程thrd_join(): 等待线程结束mtx_init(): 初始化互斥锁mtx_lock(): 锁定互斥锁mtx_unlock(): 解锁互斥锁cnd_signal(): 发送信号cnd_wait(): 等待条件变量tss_set(): 设置线程局部存储tss_get(): 获取线程局部存储tss_delete(): 删除线程局部存储
1.2 创建线程
使用 thrd_create 函数创建新线程:
#include <threads.h>
#include <stdio.h>
int thread_func(void* arg) {
printf("线程运行中...\n");
return 0;
}
int main(void) {
thrd_t thr;
int result = thrd_create(&thr, thread_func, NULL);
if (result != thrd_success) {
printf("创建线程失败\n");
return 1;
}
// 等待线程结束
thrd_join(thr, NULL);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
输出为:
线程运行中...
1.3 互斥锁
保护共享资源:
#include <threads.h>
mtx_t mutex;
int shared_data = 0;
int main(void) {
// 初始化互斥锁
if (mtx_init(&mutex, mtx_plain) != thrd_success) {
return 1;
}
// 使用互斥锁
mtx_lock(&mutex);
shared_data++; // 临界区
mtx_unlock(&mutex);
// 销毁互斥锁
mtx_destroy(&mutex);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1.4 条件变量
用于线程同步:
#include <threads.h>
mtx_t mutex;
cnd_t cond;
int ready = 0;
int producer(void* arg) {
mtx_lock(&mutex);
ready = 1;
cnd_signal(&cond);
mtx_unlock(&mutex);
return 0;
}
int consumer(void* arg) {
mtx_lock(&mutex);
while (!ready) {
cnd_wait(&cond, &mutex);
}
mtx_unlock(&mutex);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
1.5 线程局部存储
每个线程独有的存储空间:
#include <stdio.h>
#include <stdlib.h>
#include <threads.h>
tss_t key;
void destructor(void* data) {
free(data);
}
int thread_func(void* arg) {
int* value = malloc(sizeof(int));
*value = 42;
// 设置线程局部存储
tss_set(key, value);
// 获取线程局部存储
int* stored = tss_get(key);
printf("值:%d\n", *stored);
return 0;
}
int main(void) {
// 创建线程局部存储键
if (tss_create(&key, destructor) != thrd_success) {
return 1;
}
// ... 创建线程等操作 ...
tss_delete(key);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
1.6 一次性初始化
确保初始化代码只执行一次:
#include <stdio.h>
#include <threads.h>
once_flag flag = ONCE_FLAG_INIT;
void init_function(void) {
// 只执行一次的初始化代码
printf("初始化...\n");
}
int thread_func(void* arg) {
call_once(&flag, init_function);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
多个线程调用 thread_func 时,init_function 只会被调用一次,因此 初始化... 只输出一次。
2. 返回值和错误处理
<threads.h> 中函数的返回值:
thrd_success: 成功thrd_error: 一般错误thrd_nomem: 内存不足thrd_timedout: 超时thrd_busy: 资源忙
3. 其他重要函数
thrd_current(): 获取当前线程标识符thrd_equal(): 比较两个线程标识符thrd_sleep(): 线程休眠thrd_yield(): 让出 CPU 时间片
4. 习题
本页的题目围绕线程的创建与回收展开。条件变量和线程局部存储各有专页,这里只用互斥量做最基本的同步。全部题目在 Linux 上用 gcc -std=c17 -Wall -Wextra 编译;MinGW 不带 threads.h。
4.1 巩固
下面的程序创建 4 个线程,每个线程打印自己的编号并返回编号乘 10:
#include <stdio.h>
#include <threads.h>
static int worker(void *arg) {
thrd_sleep(&(struct timespec){ .tv_nsec = 50000000 }, NULL);
int id = *(int *)arg;
printf("worker %d\n", id);
return id * 10;
}
int main(void) {
thrd_t t[4];
int slot = 0;
for (int i = 0; i < 4; ++i) {
slot = i;
thrd_create(&t[i], worker, &slot);
}
for (int i = 0; i < 4; ++i) {
int res = 0;
thrd_join(t[i], &res);
printf("t[%d] -> %d\n", i, res);
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
要求:
- 先写出预测的输出,再编译运行。4 行
worker为什么都是同一个编号?t[0]到t[3]的返回值为什么相同? - 修改
main,让每个线程拿到自己的编号。验收:worker 0到worker 3各打印一行(顺序不限),t[i]的返回值为i * 10。只改main,线程函数不动。 - 去掉线程函数里的
thrd_sleep,连跑 5 次,记录 4 行worker的编号。编号可能重复,也可能各不相同,说明原因。 - 把线程函数的
return id * 10;换成thrd_exit(id * 10);,其余不动。返回值是否改变?thrd_exit之后的语句还会执行吗? - 主线程先用
thrd_current()取得自己的标识符,再在每个线程里调用thrd_equal(thrd_current(), main_id)并打印结果。验收:主线程打印 1,4 个工作线程都打印 0。标准为什么另外提供thrd_equal,而不要求代码用==比较两个thrd_t?
验收:第 1 步 4 行都是 worker 3、4 个返回值都是 30;第 2 步每个线程打印自己的编号,返回值为 i * 10;第 4 步返回值不变;第 5 步主线程为 1、工作线程全为 0。
4.2 编程
下面的程序想用一次性初始化建一张共享表,8 个线程各调用一次 ensure:
#include <stdio.h>
#include <threads.h>
static int inited;
static int init_calls;
static void init_table(void) {
++init_calls;
thrd_sleep(&(struct timespec){ .tv_nsec = 100000000 }, NULL);
inited = 1;
}
static void ensure(void) {
if (!inited) {
init_table();
}
}
static int worker(void *arg) {
(void)arg;
ensure();
return 0;
}
int main(void) {
thrd_t t[8];
for (int i = 0; i < 8; ++i) {
thrd_create(&t[i], worker, NULL);
}
for (int i = 0; i < 8; ++i) {
thrd_join(t[i], NULL);
}
printf("init_calls = %d\n", init_calls);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
要求:
- 连跑 5 次,记录
init_calls。8 个线程为什么都进了init_table? - 把
inited换成atomic_int,读写改成atomic_load与atomic_store,再跑 5 次。结果是否改变?原子读写为什么解决不了这个问题? - 用
once_flag与call_once改写ensure,其余不动。验收:init_calls = 1。 - 在
init_table的函数体开头再加一次call_once(&flag, init_table),用timeout 5 ./a.out运行,记录退出码。说明程序为什么停在初始化函数里。 - 把
call_once换成三种手写写法,各跑 5 次并记录init_calls:其一,只把inited声明为atomic_int,读用atomic_load,写用atomic_store,不加锁;其二,在mtx_lock之后检查inited再初始化;其三,在锁外先检查一次,锁内再检查一次(双重检查)。本机第一种为 8,后两种为 1。第一种就是第 2 步的写法。第三种之所以正确,靠的是默认的memory_order_seq_cst:外层那次读到 1 的加载与初始化线程的存储之间存在同步关系,初始化写入因此对它可见。把这几处的内存序都改成memory_order_relaxed,在init_table里再写一个普通对象payload = 42;,让ensure的调用者读完payload再打印。本机仍可能打印42,但这时payload的读与写已经构成数据竞争,程序行为未定义。
验收:第 1 步 init_calls = 8;第 2 步仍为 8;第 3 步为 1;第 5 步第一种为 8,后两种为 1,relaxed 版本在本机上看不出错误,能说明它为什么按标准仍然不对即可。
工作线程睡眠 300 ms 后,把一个受互斥量保护的 done 置 1。主线程有三种等待方式:反复调用 thrd_yield、反复调用 thrd_sleep 睡 10 ms、直接调用 thrd_join。
(1) [3.3] 写出前两种写法,用 clock() 测 CPU 时间、用 timespec_get 测墙钟时间。本机实测 thrd_yield 版本墙钟 300 ms、CPU 300 ms,thrd_sleep 版本墙钟 302 ms、CPU 0 ms。解释 CPU 时间的差别:thrd_yield 只是把时间片让给其他就绪线程,若没有别的可运行线程,调用者立刻被再次调度。
(2) [4.3] 用 thrd_join 完成同样的等待,测量 CPU 时间。三种写法都能等到结果,分别适合什么场合?轮询标志的写法里,done 为什么必须加锁或声明为原子类型?
(3) [4.4] 在等待循环里调用 thrd_sleep 会多出最多 10 ms 的延迟。把睡眠时间改成 100 ms、10 ms、1 ms,记录每周期的唤醒次数和总墙钟时间,说明轮询间隔与响应延迟、CPU 占用之间的取舍。
(4) [5.4**] thrd_sleep 在睡眠被信号打断时返回 -1,并通过第二个参数报告剩余时间。用 POSIX 的 setitimer 在 100 ms 后发一个 SIGALRM,主线程调用 thrd_sleep 睡 1 秒:
#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <sys/time.h>
#include <threads.h>
#include <time.h>
static volatile sig_atomic_t hits;
static void on_alarm(int sig) {
(void)sig;
++hits;
}
int main(void) {
struct sigaction sa = { 0 };
struct itimerval it = { 0 };
struct timespec dur = { .tv_sec = 1, .tv_nsec = 0 };
struct timespec rem = { 0 };
struct timespec start, end;
sa.sa_handler = on_alarm;
sigaction(SIGALRM, &sa, NULL);
it.it_value.tv_usec = 100000;
setitimer(ITIMER_REAL, &it, NULL);
clock_gettime(CLOCK_MONOTONIC, &start);
int rc = thrd_sleep(&dur, &rem);
clock_gettime(CLOCK_MONOTONIC, &end);
long ms = (end.tv_sec - start.tv_sec) * 1000 +
(end.tv_nsec - start.tv_nsec) / 1000000;
printf("rc = %d, hits = %d, elapsed = %ld ms, rem = %ld.%09ld\n", rc,
(int)hits, ms, (long)rem.tv_sec, rem.tv_nsec);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
验收:退出码 0,rc = -1,hits = 1,elapsed 约 100 ms,rem 约 0.9 秒。说明想在信号后继续睡完剩下的时间应该怎么写。
4.3 综合与挑战
用递归分治把 1 到 100000 求和:每个线程负责一个区间,区间长度不小于 1000 时把区间对半切开,再创建两个线程分别求和。
typedef struct {
long lo, hi;
long sum;
} range_t;2
3
4
(1) [4.4] 实现 range_sum,在线程函数内部用 thrd_create 创建子线程,并用 thrd_join 汇总结果。验收:sum = 5000050000。用 atomic_int 统计创建过的线程总数(创建后先 atomic_fetch_add 再创建),记录这个数。区间阈值取 1000 时是多少?取 10000 时是多少?由此说明线程数量与递归深度的关系。
(2) [5.5] 主线程不等待,直接 return 0; 结束进程时,尚未完成的工作线程会怎样?写一个程序验证:4 个工作线程各睡 300 ms 后打印一行,main 创建完线程就返回。再把 return 0; 换成 thrd_exit(0);,结果有何不同?thrd_exit 与 exit 对线程的影响分别是什么?
(3) [5.5] thrd_detach(thrd_t thr) 把一个线程标记为不可 join,成功返回 thrd_success。对同一个线程先 thrd_detach 再 thrd_join,第二次调用返回什么?对同一个线程调用两次 thrd_join,第二次返回什么?写程序验证,并说明可 join 的线程为什么必须被 join 或 detach。
(4) [6.5*] 每个区间都创建一个线程,线程数随区间长度线性增长,创建和销毁的开销很大。改成固定 4 个工作线程,由一个共享的任务队列分配区间。只要求给出结构与同步方案,说明主线程怎样知道全部任务完成,不必写完整代码。
验收:第 1 步 sum = 5000050000,阈值为 1000 时创建 255 个线程,阈值为 10000 时创建 31 个;第 2 步返回 0 时工作线程一行都不打印,thrd_exit(0) 时 4 行全部打印;第 3 步 detach 后的 join 与第二次 join 都返回 thrd_error。