OSTEP 学习 | 多线程的一个经典例子——竞态条件
多线程的一个经典函数
代码
#include <stdio.h> // 标准输入输出:printf、fprintf
#include <stdlib.h> // 标准库:atoi、exit
#include <pthread.h> // 线程库:pthread_create、pthread_join
#include "common.h" // 包含 Pthread_create、Pthread_join 的封装和可能的错误检查
/* 全局变量,用来记录两个线程对同一个计数器的累加操作 */
volatile int counter = 0;
int loops; /* 从命令行参数读取,要让每个线程循环多少次 */
void *worker(void *arg) {
int i;
/* 每个线程都会执行 loops 次 counter++ 操作 */
for (i = 0; i < loops; i++) {
counter++;
}
return NULL;
}
int main(int argc, char *argv[])
{
/* 1. 检查命令行参数 */
if (argc != 2) {
fprintf(stderr, "usage: threads <value>\n");
exit(1);
}
/* 2. 将第一个参数(字符串)转换成整数,赋值给 loops */
loops = atoi(argv[1]);
pthread_t p1, p2; /* 两个线程的句柄 */
printf("Initial value : %d\n", counter);
/* 3. 创建两个线程,让它们都去执行 worker 函数 */
Pthread_create(&p1, NULL, worker, NULL);
Pthread_create(&p2, NULL, worker, NULL);
/* 4. 等待这两个线程结束 */
Pthread_join(p1, NULL);
Pthread_join(p2, NULL);
/* 5. 所有线程完成后,打印 counter 的最终值 */
printf("Final value : %d\n", counter);
return 0;
}
详细解释
好的,我们来一步一步地详细解释一下这段 C 语言程序。从一个初学者的角度来看,这段代码是学习多线程编程时一个非常经典的例子,它主要用来演示一个叫做“竞态条件 (Race Condition)”的问题。
代码的总体目标
想象一下,你有一个计数器,你希望两个“工人”(线程)同时去增加这个计数器的值。如果每个工人都增加它 1000 次,你自然会期望最终结果是 2000。但这段程序会告诉你,结果可能并非如此。
逐行解释
让我们把代码分成几个部分来看:
第 1-3 行:包含头文件
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include "common.h"
#include <stdio.h>:这是标准输入输出库 (Standard Input/Output)。包含了我们后面要用到的printf(打印信息到屏幕) 和fprintf(打印错误信息) 函数。#include <stdlib.h>:这是标准库 (Standard Library)。我们用到了其中的atoi函数(将文本转换为整数)和exit函数(退出程序)。#include "common.h":这通常是一个自定义的头文件,在这个例子里,它很可能包含了一些对pthread函数的封装,比如Pthread_create和Pthread_join,目的是简化错误检查。在标准的 C 语言中,直接使用的是pthread_create和pthread_join。
第 5-6 行:全局变量
5 volatile int counter = 0;
6 int loops;
volatile int counter = 0;:这里定义了一个全局变量counter,并初始化为 0。- 全局变量:意味着这个
counter变量可以被程序中的任何函数访问和修改,包括我们之后创建的多个线程。 volatile关键字:这是一个比较进阶的概念。它告诉编译器,这个变量的值随时可能在程序没有明确操作它的情况下发生改变(比如被另一个线程修改)。这会阻止编译器做一些可能导致错误的优化。简单来说,它确保程序每次都从内存中读取counter的最新值,而不是使用可能过时的缓存值。
- 全局变量:意味着这个
int loops;:这也是一个全局变量,它将用来存储每个线程需要循环增加计数器的次数。
第 8-14 行:worker 函数 (工人函数)
8 void *worker(void *arg) {
9 int i;
10 for (i = 0; i < loops; i++) {
11 counter++;
12 }
13 return NULL;
14 }
- 这个函数是每个“工人”(线程)具体要执行的任务。
void *worker(void *arg):这是线程函数的标准写法。它接受一个通用指针arg作为参数(虽然本例中没用上),并返回一个通用指针(本例中返回NULL,表示没有特别的返回值)。for (i = 0; i < loops; i++) { ... }:一个简单的循环,循环次数由全局变量loops决定。counter++;:这是整个程序的核心!在循环的每一次中,这个线程都会去给全局变量counter的值加 1。
第 16-33 行:main 函数 (主函数)
16 int
17 main(int argc, char *argv[])
18 {
// ...
33 }
这是程序的入口,是程序开始执行的地方。
-
第 19-23 行:处理输入参数
19 if (argc != 2) { 20 fprintf(stderr, "usage: threads <value>\n"); 21 exit(1); 22 } 23 loops = atoi(argv[1]);- 这段代码检查你运行程序时是否提供了一个参数。
argc是参数的数量,argv是参数的内容。 - 如果你运行
./threads而没有带数字,argc会是 1,程序会打印错误信息并退出。 - 你必须这样运行:
./threads 1000。这时argc是 2,argv[1]就是字符串"1000"。 loops = atoi(argv[1]);这行代码会将字符串"1000"转换成整数1000,并存入loops变量。
- 这段代码检查你运行程序时是否提供了一个参数。
-
第 24-25 行:创建线程前的准备
24 pthread_t p1, p2; 25 printf("Initial value : %d\n", counter);pthread_t p1, p2;:定义了两个线程标识符p1和p2。你可以把它们想象成是未来两个工人的“工牌”,用来区分它们。printf(...):打印计数器的初始值,此刻应该是 0。
-
第 27-28 行:创建两个线程
27 Pthread_create(&p1, NULL, worker, NULL); 28 Pthread_create(&p2, NULL, worker, NULL);- 这是最关键的一步。程序在这里创建了两个新的线程。
Pthread_create函数会告诉操作系统:“请帮我创建一个新的执行流,让它从worker函数开始运行。”- 我们创建了
p1和p2两个线程,并且它们都去执行worker函数。这意味着,从这一刻起,你的程序中就有了三个执行流在同时运行:主函数(main)、线程p1、线程p2。
-
第 29-30 行:等待线程结束
29 Pthread_join(p1, NULL); 30 Pthread_join(p2, NULL);- 主函数(main)在创建完两个工人后,不能立刻就去打印最终结果,因为它不知道工人们干完活了没有。
Pthread_join函数的作用就是“等待”。Pthread_join(p1, NULL);的意思是:“主函数,请在这里暂停,一直等到p1线程完成了它的全部工作(即worker函数执行完毕)再继续往下走。”- 所以,这两行保证了主函数会等到两个工人线程都结束后,才继续执行。
-
第 31-32 行:打印最终结果
31 printf("Final value : %d\n", counter); 32 return 0;- 当程序执行到这里时,可以确定两个
worker线程都已经结束了。 printf(...):打印出全局变量counter的最终值。return 0;:程序正常结束。
- 当程序执行到这里时,可以确定两个
核心问题:为什么结果可能不正确?(竞态条件)
如果你用一个比较大的数字(比如 100000)来运行这个程序,你会发现最终打印的 Final value 很可能不是 200000,而是一个比它小的值。
为什么呢? 问题就出在 counter++ 这行代码上。
对于我们来说,counter++ 是一步操作。但对于计算机的 CPU 来说,它实际上包含了三个步骤:
- 读取 (Load):从内存中把
counter的当前值读取到 CPU 的寄存器中。 - 增加 (Update):在 CPU 寄存器中,把这个值加 1。
- 写回 (Store):把寄存器里计算好的新值,写回到内存中的
counter位置。
现在想象一下两个线程同时执行的场景:
- 假设
counter的当前值是 50。 - 线程 p1 读取
counter的值(50)到自己的寄存器里。 - 此时,操作系统可能暂停了线程 p1,开始运行线程 p2。(这就是多线程的并发执行)
- 线程 p2 也去读取
counter的值,由于 p1 还没来得及把新值写回去,所以 p2 读取到的值仍然是 50。 - 线程 p2 在自己的寄存器里把 50 加 1,得到 51,然后把 51 写回到内存的
counter。现在counter的值是 51。 - 过了一会儿,操作系统又切换回线程 p1。p1 继续从它上次中断的地方开始,它把自己寄存器里的值(50)加 1,得到 51。
- 线程 p1 把这个 51 写回到内存的
counter。
问题出现了:两个线程都执行了一次 counter++,我们期望的结果是 counter 变成 52。但由于它们交错执行,互相覆盖了对方的工作,最终结果只有 51。一个 +1 操作丢失了!
当成千上万次这样的操作交织在一起时,就会丢失大量的计数,导致最终结果远小于预期值。这种情况就是竞态条件 (Race Condition),因为多个线程在“竞争”同一个共享资源(counter),最终结果取决于它们执行的时序。
要解决这个问题,就需要使用“锁” (Mutex) 等同步机制,来确保同一时间只有一个线程可以执行 counter++ 这段关键代码。
common.c 的实现
好的,为了完成 common.c 里面的 Pthread_create 和 Pthread_join 函数,我们首先需要一个 common.h 头文件来声明这些函数。这些封装函数的主要目的是在调用底层的 pthread_create 和 pthread_join 时,增加错误检查,如果出现错误就打印信息并退出程序。
1. common.h 文件
创建一个名为 common.h 的文件,内容如下:
#ifndef __COMMON_H__
#define __COMMON_H__
#include <pthread.h> // 包含 POSIX 线程库的头文件
// 声明封装后的 Pthread_create 函数
// 参数与 pthread_create 相同
void Pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
// 声明封装后的 Pthread_join 函数
// 参数与 pthread_join 相同
void Pthread_join(pthread_t thread, void **retval);
#endif // __COMMON_H__
解释:
#ifndef __COMMON_H__和#define __COMMON_H__以及#endif是防止头文件被重复包含的“卫士 (Include Guards)”。#include <pthread.h>:这是使用pthread相关函数所必须的头文件。- 我们声明了两个
void类型的函数,因为它们在出错时会直接exit,所以不需要返回错误码。它们的参数与标准的pthread_create和pthread_join完全一致。
2. common.c 文件
创建一个名为 common.c 的文件,内容如下:
#include "common.h" // 包含我们自己的头文件
#include <stdio.h> // 用于 fprintf 和 perror
#include <stdlib.h> // 用于 exit
// Pthread_create 函数的实现
void Pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg) {
int rc = pthread_create(thread, attr, start_routine, arg);
if (rc != 0) { // 如果 pthread_create 返回非零值,表示出错
// perror 是一个方便的函数,它会打印一个错误信息,然后跟着 strerror(errno) 的内容
// 也就是说,它会打印你提供的字符串,然后是系统对最近错误的描述
fprintf(stderr, "Pthread_create error: %d\n", rc); // 打印错误码
exit(EXIT_FAILURE); // 退出程序,通常 EXIT_FAILURE 是 1
}
}
// Pthread_join 函数的实现
void Pthread_join(pthread_t thread, void **retval) {
int rc = pthread_join(thread, retval);
if (rc != 0) { // 如果 pthread_join 返回非零值,表示出错
fprintf(stderr, "Pthread_join error: %d\n", rc); // 打印错误码
exit(EXIT_FAILURE); // 退出程序
}
}
解释:
#include "common.h":包含了我们上面定义的头文件,这样编译器就知道Pthread_create和Pthread_join的声明了。#include <stdio.h>和#include <stdlib.h>:用于错误处理和程序退出。- 在每个函数内部,我们首先调用对应的标准
pthread_函数,并将其返回值存入rc(return code) 变量。 - 错误检查:
if (rc != 0)这段代码检查pthread_函数的返回值。pthread函数通常在成功时返回 0,在失败时返回一个非零的错误码。 fprintf(stderr, ...):将错误信息打印到标准错误流 (standard error stream),而不是标准输出流。这是一种良好的编程实践,方便将正常的输出和错误信息区分开。exit(EXIT_FAILURE):如果发生错误,程序将立即终止,并返回一个表示失败的状态码给操作系统。
3. 如何编译和运行
现在你有了三个文件:threads.c (你提供的原始代码), common.h, 和 common.c。
你需要将它们一起编译。在 Linux 或 macOS 系统上,你可以使用 gcc 编译器:
gcc -o threads threads.c common.c -Wall -pthread
解释编译命令:
gcc: C 语言编译器。-o threads: 指定输出的可执行文件名为threads。threads.c common.c: 告诉编译器要编译这两个源文件。-Wall: 开启所有常见的警告(推荐使用,有助于发现潜在问题)。-pthread: 这是非常重要的一个编译选项。它告诉编译器和链接器,你的程序使用了 POSIX 线程库,因此需要链接到对应的库文件。
编译成功后,你就可以运行你的程序了:
./threads 1000000
你仍然会观察到 Final value 可能不是你预期的 2000000,这正是这个例子要展示的竞态条件问题。这些 Pthread_ 封装函数只是让错误处理更健壮,并不会解决竞态条件本身。
多线程的一些函数解释
没问题!我们来详细解释一下这些在多线程编程中非常核心的概念和函数参数。对于初学者来说,这些概念确实需要一些时间来消化。
1. pthread_t:线程的“身份证”或“工牌”
pthread_t是一个数据类型,它用来存储一个线程的唯一标识符(ID)。你可以把它想象成每个“工人”(线程)的“身份证号码”或“工牌号”。- 当你创建一个新的线程时,操作系统会给这个线程分配一个唯一的
pthread_t类型的值,你的程序可以用这个值来引用这个特定的线程,例如,当你想等待它完成工作时。 - 在
threads.c例子中,pthread_t p1, p2;就是定义了两个变量,用来存放未来创建的两个线程的 ID。
2. pthread_create():创建新线程的函数
pthread_create 是 POSIX 线程库中用来创建新线程的核心函数。当你调用它时,系统会启动一个新的执行流,和你的主程序并行运行。
它的完整原型(在 pthread.h 中)是:
int pthread_create(pthread_t *thread, // [输出] 新线程的ID
const pthread_attr_t *attr, // [输入] 线程属性
void *(*start_routine) (void *), // [输入] 线程要执行的函数
void *arg); // [输入] 传给线程函数的参数
现在,我们来解释你在 Pthread_create 封装函数中看到的每一个参数:
-
pthread_t *thread- 类型:这是一个指向
pthread_t类型变量的指针。 - 作用:这是一个输出参数。当你调用
pthread_create成功后,它会把新创建线程的唯一 ID 存储到这个指针所指向的内存位置。 - 例子:在
Pthread_create(&p1, ...)中,&p1就是p1变量的地址,pthread_create会把新线程的 ID 放到p1中。
- 类型:这是一个指向
-
const pthread_attr_t *attr- 类型:这是一个指向
pthread_attr_t类型变量的常量指针。pthread_attr_t是用来定义线程属性(比如线程的栈大小、调度策略等)的结构体。 - 作用:这是一个输入参数,用来指定新线程的属性。
- 初学者角度:对于大多数简单的多线程程序,你通常不需要设置特殊的线程属性,所以这里传入
NULL即可。传入NULL意味着使用默认的线程属性。
- 类型:这是一个指向
-
void *(*start_routine) (void *)- 类型:这是一个函数指针。它指向一个函数,这个函数必须接受一个
void *类型的参数,并且返回一个void *类型的值。 - 作用:这是一个输入参数,它告诉新创建的线程应该从哪个函数开始执行。这个函数就是新线程的“入口点”或“工作内容”。
- 例子:在
Pthread_create(&p1, NULL, worker, NULL);中,worker就是这个函数。这意味着新创建的线程p1会开始执行worker函数里面的代码。
- 类型:这是一个函数指针。它指向一个函数,这个函数必须接受一个
-
void *arg- 类型:这是一个
void *类型的指针。 - 作用:这是一个输入参数,它允许你向新创建的线程的
start_routine函数传递一个参数。这个参数是通用的指针类型,所以你可以传递任何类型的数据(但通常需要进行类型转换)。 - 例子:在
threads.c中,worker函数不需要额外的输入参数,所以这里传入的是NULL。如果你想给worker函数传递一个整数,你可能需要(void *)&my_int_variable这样的形式。
- 类型:这是一个
3. pthread_join():等待线程结束的函数
pthread_join 是 POSIX 线程库中用来等待一个线程结束的函数。当主线程(或其他线程)调用 pthread_join 时,它会暂停自己的执行,直到指定的那个线程完成它的工作并终止。
它的完整原型是:
int pthread_join(pthread_t thread, // [输入] 要等待的线程的ID
void **retval); // [输出] 线程的返回值
现在,我们来解释你在 Pthread_join 封装函数中看到的每一个参数:
-
pthread_t thread- 类型:这是一个
pthread_t类型的值。 - 作用:这是一个输入参数,它指定了你想要等待哪个线程完成。
- 例子:在
Pthread_join(p1, NULL);中,p1就是我们之前创建的第一个线程的 ID。这行代码的意思是主线程在这里等待p1线程完成。
- 类型:这是一个
-
void **retval- 类型:这是一个指向
void *类型变量的指针。 - 作用:这是一个输出参数。如果被等待的线程(即
start_routine函数)返回了一个值(通过return some_value;),那么这个返回值会被存储到retval指向的内存位置。 - 初学者角度:在
threads.c的例子中,worker函数返回NULL,而且我们也不关心它的返回值,所以这里传入NULL。如果你想获取线程的返回值,你需要定义一个void *类型的变量,并将这个变量的地址传给pthread_join,例如void *status; Pthread_join(p1, &status);。
- 类型:这是一个指向
总结
pthread_t:线程的 ID。pthread_create():启动一个新线程,让它去执行你指定的函数。pthread_join():等待一个已经启动的线程完成它的工作。
理解了这三个核心概念和它们的参数,你就迈出了多线程编程的第一步! common.c 中的 Pthread_create 和 Pthread_join 只是对这些标准函数的简单封装,加上了错误检查,让你的代码更健壮,但它们的参数和功能与标准库函数是完全一致的。

浙公网安备 33010602011771号