Day 20-21:Linux多线程编程—— 让程序同时干好几件事

单核CPU就像只有一个收银员的超市,顾客排长队。多线程就是给超市多雇几个收银员,大家同时工作,效率翻倍。


认识"进程"和"线程"——工厂和工人的比喻

在开始写代码之前,先用一个比喻帮你建立直觉:

  • 进程 = 一座独立的工厂。它有自己的围墙(内存空间)、厂房、食堂。
  • 线程 = 工厂里的工人。多个工人共享厂房和仓库,但各干各的活,各有各的工具箱。

关键区别速查:

进程(Process)线程(Thread)
创建成本高(要建一整座工厂)低(多雇一个工人就行)
通信方式需要管道/信号等"对讲机"直接喊话(共享内存,读写全局变量)
一个崩了其他不受影响全完蛋(整个进程崩溃)
适用场景Chrome每个标签页独立Web服务器同时处理多个请求

今天我们学的就是让一个程序里开多个"工人"(线程),让它们同时干活。


第1步:配置编译环境

按照前面学习过来的,这一步可以直接跳过。

1.1 打开终端

按键盘上的 Ctrl + Alt + T 组合键,会弹出一个黑底白字的窗口,这就是终端(Terminal)——你和Linux对话的地方,输入命令后按回车执行。

1.2 确认 gcc 已安装

在终端里输入以下命令,然后按回车:

# 查看 gcc 版本,确认已安装
gcc --version

如果显示版本号(如 gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0),说明已安装,跳到 1.4

如果提示 command not found,说明没装,继续 1.3。

1.3 安装 gcc(如果没装的话)

# 安装 gcc 编译器
sudo apt install gcc -y

⚠️ 预警:sudo 会让你输入密码(就是登录 Ubuntu 的密码),输入时屏幕上不会显示任何字符(连 *** 都没有),这是Linux的安全设计,别慌,输完直接按回车。

1.4 创建一个工作目录

# 在你的主目录下创建练习文件夹
mkdir ~/linux_thread_practice

# 进入这个文件夹
cd ~/linux_thread_practice

从现在开始,所有代码都在 ~/linux_thread_practice 这个文件夹里编写和编译。


第2步:创建你的第一个线程

大白话解释

pthread_create 就是"雇一个工人"——告诉操作系统:我要一个新线程,让它去执行我指定的函数。等它干完活,用 pthread_join "等工人收工"。

📌 术语卡pthread = POSIX Thread 的缩写,是Linux下最标准的线程库。可以理解为"Linux官方线程工具包"。

2.1 创建源代码文件

在终端输入:

# 用 gedit 创建新文件(如果没装 gedit,改成 nano hello_thread.c)
gedit hello_thread.c

这会打开一个空白的文本编辑器窗口。

2.2 输入以下代码

在编辑器里完整输入下面这段代码(每一步都有注释解释,// 后面是说明):

#include <stdio.h>   // 标准输入输出,printf 需要它
#include <pthread.h> // 线程库头文件,pthread_create 等函数需要它
#include <unistd.h>  // sleep 函数需要它

// 这就是线程要干活的函数
// void* 是"通用指针类型",可以理解成"什么地址都能接"
void* worker(void *arg) {
    // 把传进来的参数转成 int* 类型,再取出里面的数字
    int id = *(int*)arg;
    
    printf("我是线程 %d,正在努力工作...\n", id);
    sleep(1);  // 假装在工作,等1秒
    printf("线程 %d 完成工作!\n", id);
    
    return NULL;  // 线程结束,返回空
}

int main() {
    pthread_t t1, t2;  // 声明两个线程变量(类似 int a, b)
    int id1 = 1, id2 = 2;  // 给两个线程准备不同的编号
    
    // 创建线程1:让它去执行 worker 函数,传给它的参数是 id1 的地址
    // pthread_create(线程变量, 属性(默认NULL), 函数名, 参数)
    pthread_create(&t1, NULL, worker, &id1);
    
    // 创建线程2:同理
    pthread_create(&t2, NULL, worker, &id2);
    
    // 等待两个线程都干完活再继续(不join的话主线程可能提前跑路)
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    
    printf("所有线程执行完毕!\n");
    return 0;
}

2.3 保存文件

按 Ctrl + S 保存(或者点菜单栏的 Save 按钮),然后关闭编辑器。

2.4 编译代码

回到终端,输入以下命令:

# 编译线程程序(注意:必须加 -pthread,否则会报错!)
gcc -pthread hello_thread.c -o hello_thread

⚠️ 预警:-pthread 这个参数绝对不能漏!如果忘了加,编译时会报 undefined reference to pthread_create 错误。记忆口诀:"用线程就要加 -pthread"

如果编译成功,不会有任何输出——Linux的哲学:"没有消息就是好消息"。

2.5 运行程序

# 运行编译好的程序(./ 表示"当前目录下")
./hello_thread

你应该看到类似这样的输出:

💡 线程1和线程2的输出顺序可能不一样(有时1先,有时2先),这完全正常,说明两个线程真的在同时跑!


第3步:理解 pthread_join —— "等工人干完活"

pthread_join(线程名, NULL) 就像是"主线程在门口等着,直到子线程干完活才走"。如果不 join,主线程可能提前退出,子线程还没干完活就被强行终止了。

要点速记

  • 第一个参数:要等的线程
  • 第二个参数:NULL 表示"我不关心线程的返回值"
  • 每创建一个线程,就要对应一次 join(或者用第7步讲的 detach),否则可能造成内存泄漏(像离职的工人工具箱没回收)

第4步:互斥锁(Mutex)—— 防止"抢钱"问题

互斥锁(Mutex) 就像公共厕所的门锁:一个人进去后锁上门,别人在外面等着。等他出来开了锁,下一个才能进去。

在程序里,"厕所"就是临界区(Critical Section)——同时只能被一个线程访问的代码段。那些被多个线程共享的变量(如银行余额),就是需要"锁门"保护的对象。

4.1 "数据竞争"是什么?

假设银行账户余额 balance = 1000,两个线程各往里存 100 元,理想结果应该是 1200。

但 balance += 100 在计算机内部是分成三步的:

  1. 从内存读取余额到 CPU
  2. CPU 加上 100
  3. 写回内存

如果两个线程的操作交叉执行(线程A刚读完还没写,线程B也读了同一旧值),就会丢钱!这就是数据竞争(Race Condition)

4.2 创建测试代码

# 创建新文件
gedit mutex_demo.c

4.3 输入以下代码

#include <stdio.h>
#include <pthread.h>

int balance = 1000;  // 共享余额,初始1000元

// 创建互斥锁并初始化
// PTHREAD_MUTEX_INITIALIZER 是"默认初始化"的快捷写法
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

// 存款函数:每次都加上指定金额
void* deposit(void *arg) {
    int amount = *(int*)arg;  // 取出要存的金额
    for (int i = 0; i < 1000000; i++) {  // 存100万次
        pthread_mutex_lock(&lock);    // ① 上锁:"我进去了,别人等着"
        balance += amount;            // ② 安全地修改余额
        pthread_mutex_unlock(&lock);  // ③ 开锁:"我出来了,下一个"
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;       // 两个线程
    int add1 = 10, add2 = 10;  // 每个线程每次存10元
    
    printf("初始余额: %d\n", balance);
    
    // 创建两个线程同时存钱
    pthread_create(&t1, NULL, deposit, &add1);
    pthread_create(&t2, NULL, deposit, &add2);
    
    // 等两个线程都存完
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    
    // 理论值:1000 + 10*1000000*2 = 20001000
    printf("最终余额: %d\n", balance);
    printf("理论值: %d\n", 1000 + 10 * 1000000 * 2);
    
    pthread_mutex_destroy(&lock);  // 用完销毁锁(好习惯)
    return 0;
}

4.4 编译并运行

# 编译
gcc -pthread mutex_demo.c -o mutex_demo

# 运行
./mutex_demo

输出应该是:

最终余额和理论值一致,说明锁生效了。

💡 小实验:试试把代码里的 pthread_mutex_lock(&lock) 和 pthread_mutex_unlock(&lock) 两行前面加 // 注释掉(变成 //pthread_mutex_lock...),重新编译运行,看看余额是不是变少了——这就是数据竞争的后果!

4.5 互斥锁使用口诀

上锁 → 干活 → 解锁
lock → 操作   → unlock

⚠️ 铁律:不管代码逻辑多复杂,lock 和 unlock 必须成对出现。如果中间有 return,一定要在 return 之前先 unlock,否则其他线程会永远等下去(这就是死锁的常见原因之一)。


第5步:条件变量(Condition Variable)—— "没活就睡觉,有活被叫醒"

互斥锁解决了"不能同时写"的问题,但解决不了"没活干时怎么办"的问题。

条件变量就是一个呼叫铃:

  • 消费者线程发现没货了 → 按铃等待(pthread_cond_wait),自己睡觉不占CPU
  • 生产者线程做好货了 → 按铃唤醒(pthread_cond_signal),把睡觉的消费者叫起来

这比"一直问'好了没好了没'"要高效得多(不浪费CPU)。

5.1 核心函数速查

函数作用大白话
pthread_cond_wait(cond, mutex)睡觉等待,同时自动开锁"先开厕所门,然后在门外睡觉等"
pthread_cond_signal(cond)叫醒一个等待的线程(随机)"喂,该你了"
pthread_cond_broadcast(cond)叫醒所有等待的线程"所有人注意,开工了!"
pthread_cond_init / PTHREAD_COND_INITIALIZER初始化条件变量"安装呼叫铃"
pthread_cond_destroy销毁条件变量"拆掉呼叫铃"

⚠️ 重要预警——虚假唤醒(Spurious Wakeup):pthread_cond_wait 有时候会"自己莫名醒来"(操作系统调度导致的),这叫做虚假唤醒。所以被唤醒后一定要再次检查条件是否真的满足。这就是为什么条件判断必须用 while 而不是 if!

5.2 为什么必须用 while 而不是 if?

// ❌ 错误写法(可能出bug)
if (buffer == 0) {
    pthread_cond_wait(&cond, &lock);  // 醒来后直接往下走,不管条件是否真满足
}

// ✅ 正确写法
while (buffer == 0) {
    pthread_cond_wait(&cond, &lock);  // 醒来后再检查一次,不满足继续睡
}
// 走到这里时,buffer 一定不等于 0,绝对安全!

5.3 实战:生产者-消费者模型(厨师与服务员)

场景:一个厨师(生产者)做菜,一个服务员(消费者)上菜。只有一张桌子(缓冲区),桌上要么有菜要么没菜。

第1小步:创建文件
gedit producer_consumer.c
第2小步:输入完整代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 缓冲区:0表示桌子空着,非0表示桌上有菜(数字是菜编号)
int buffer = 0;

// 互斥锁和条件变量
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

// 生产者(厨师)
void* producer(void *arg) {
    for (int i = 0; i < 5; i++) {
        pthread_mutex_lock(&lock);
        
        // 如果桌上还有菜(buffer != 0),厨师睡觉等服务员端走
        while (buffer != 0) {
            pthread_cond_wait(&cond, &lock);
        }
        
        // 做菜!放在桌上
        buffer = i + 1;
        printf("厨师:做好了第 %d 道菜\n", buffer);
        
        // 按铃叫醒服务员
        pthread_cond_signal(&cond);
        pthread_mutex_unlock(&lock);
        
        sleep(1);  // 做菜需要时间
    }
    return NULL;
}

// 消费者(服务员)
void* consumer(void *arg) {
    for (int i = 0; i < 5; i++) {
        pthread_mutex_lock(&lock);
        
        // 如果桌上没菜(buffer == 0),服务员睡觉等厨师做好
        while (buffer == 0) {
            pthread_cond_wait(&cond, &lock);
        }
        
        // 上菜!
        printf("服务员:端走了第 %d 道菜\n", buffer);
        buffer = 0;  // 桌子清空
        
        // 按铃叫醒厨师(告诉他桌子空了,可以做下一道)
        pthread_cond_signal(&cond);
        pthread_mutex_unlock(&lock);
        
        sleep(1);  // 上菜需要时间
    }
    return NULL;
}

int main() {
    pthread_t p, c;
    
    // 创建厨师线程和服务员线程
    pthread_create(&p, NULL, producer, NULL);
    pthread_create(&c, NULL, consumer, NULL);
    
    // 等两个线程都干完
    pthread_join(p, NULL);
    pthread_join(c, NULL);
    
    // 清理资源
    pthread_mutex_destroy(&lock);
    pthread_cond_destroy(&cond);
    
    printf("打烊了!\n");
    return 0;
}
第3小步:编译运行
gcc -pthread producer_consumer.c -o producer_consumer
./producer_consumer

预期输出:

输出严格交替——生产者和消费者完美配合,一唱一和!


第6步:综合实战——用4个线程并行计算1000万个数字的总和

如果你要数 1000 万张钞票,一个人数要很久。找4个人,每人负责四分之一,最后把4份加起来——这就是"分而治之"(分治算法)。

6.1 创建文件

gedit parallel_sum.c

6.2 输入完整代码

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>

#define NUM_THREADS 4       // 用4个线程(工人)
#define SIZE 10000000       // 数组大小:1000万个数字

long long arr[SIZE];                    // 大数组,存1000万个数字
long long partial_sums[NUM_THREADS];    // 每个线程的"分区小计"

// 每个线程计算自己的那一段
void* sum_part(void *arg) {
    int tid = *(int*)arg;  // 线程编号:0, 1, 2, 3
    
    // 计算本线程负责的范围
    int chunk = SIZE / NUM_THREADS;           // 每人分多少
    int start = tid * chunk;                   // 起点
    int end = (tid == NUM_THREADS - 1)         // 终点(最后一个人包揽剩余部分)
              ? SIZE 
              : start + chunk;
    
    long long sum = 0;
    for (int i = start; i < end; i++) {
        sum += arr[i];
    }
    
    partial_sums[tid] = sum;  // 存到对应位置
    printf("线程 %d 计算完成:从索引 %d 到 %d,小计 = %lld\n", 
           tid, start, end - 1, sum);
    return NULL;
}

int main() {
    // 初始化数组:arr[0]=1, arr[1]=2, ..., arr[9999999]=10000000
    for (int i = 0; i < SIZE; i++) {
        arr[i] = i + 1;
    }
    printf("数组初始化完成:%d 个元素\n", SIZE);
    
    pthread_t threads[NUM_THREADS];  // 4个线程
    int ids[NUM_THREADS];            // 4个编号
    
    // 启动4个线程,并行计算
    for (int i = 0; i < NUM_THREADS; i++) {
        ids[i] = i;
        pthread_create(&threads[i], NULL, sum_part, &ids[i]);
    }
    
    // 等所有线程算完,汇总
    long long total = 0;
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], NULL);
        total += partial_sums[i];
    }
    
    // 理论值:1+2+...+N = N*(N+1)/2
    printf("\n并行计算结果: %lld\n", total);
    printf("理论值:        %lld\n", (long long)SIZE * (SIZE + 1) / 2);
    
    return 0;
}

6.3 编译运行

gcc -pthread parallel_sum.c -o parallel_sum
./parallel_sum

预期输出:

两者一致,分而治之正确!在多核机器上你会明显感觉比单线程快。


第7步:pthread_detach —— "不用等我,干完自动消失"

有些线程就像发传单的临时工——干完活就走,不需要主线程等它。用 pthread_detach 标记这种线程,它结束后系统会自动回收资源。

用法

// 方式1:在线程函数内部调用,把自己设为"分离状态"
pthread_detach(pthread_self());

// 方式2:主线程在创建线程后立即 detach
pthread_t t;
pthread_create(&t, NULL, worker, NULL);
pthread_detach(t);  // 告诉系统:别管它,它自己会走,不用 join

⚠️ 预警:一旦 detach 了,就不能再 join 这个线程。分离线程和 join 只能二选一,否则会报错。


避坑指南:多线程编程四大天坑

天坑现象根本原因预防方法
数据竞争共享变量值忽大忽小,结果随机,每次运行结果不一样多个线程同时读写同一变量,不加锁所有访问共享变量的地方必须加同一把锁
死锁程序突然卡住不动,CPU占用为0,只能 Ctrl+C 强制终止线程A等线程B的锁,线程B又等线程A的锁,互相等①避免嵌套锁;②如果必须多把锁,所有人按相同顺序获取(比如都先锁A再锁B)
虚假唤醒程序偶尔出bug,难以复现,十次里可能就一次错cond_wait 可能被操作系统"虚假唤醒",条件并不满足cond_wait 必须放在 while 循环里,醒来后重新检查条件
内存泄漏程序运行越久内存占用越大,最终系统变卡创建了线程但既没 join 也没 detach每个线程要么 join,要么 detach,不能创建了就忘

作业(巩固练习)

  1. 基础:创建4个线程,分别打印 125、2650、5175、76100。先不加锁观察输出混乱的样子,再加锁让输出有序。

  2. 进阶:把生产者-消费者模型扩展成"2个厨师 + 2个服务员"(多生产者多消费者),用 pthread_cond_broadcast 唤醒所有人。

  3. 挑战:用多线程实现归并排序。主线程把数组劈成两半,交给两个子线程分别排序,最后合并结果。

  4. 终极挑战:实现一个简易线程池(Thread Pool)。主线程提前创建N个线程在"睡觉等活",有任务来了就唤醒一个去干,干完继续睡。提示:用条件变量实现任务队列。


常见问题速查表(FAQ)

问题原因解决办法
编译报 undefined reference to pthread_create忘了加 -pthread编译命令改成 gcc -pthread 文件.c -o 文件
pthread_cond_wait 一直不返回,程序卡住没有线程调用 signal 来唤醒,或锁没释放检查生产者是否确实调用了 pthread_cond_signal;检查 unlock 是否被跳过
程序跑着跑着卡死,CPU占用0大概率是死锁Ctrl+C 终止程序,检查是否有嵌套锁、锁顺序不一致,或用 gdb 调试查看线程堆栈
线程里改了全局变量,主线程读到的却是旧值编译器优化把变量缓存到CPU寄存器了给变量加 volatile 关键字,或用 gcc -O0 关闭优化编译
主线程结束,子线程还在跑被强杀主线程没等子线程就退出了主线程必须调用 pthread_join 等待,或调用 pthread_exit(0) 让进程等所有线程
运行 ./hello_thread 提示 Permission denied文件没有执行权限运行 chmod +x hello_thread 添加执行权限
输入 gedit 提示 command not found没装 gedit改用 nano 文件名 或 vim 文件名,或安装:sudo apt install gedit -y
不知道文件保存到哪了没注意当前目录用 pwd 命令查看当前目录,所有文件都在 ~/linux_thread_practice/ 下

总结口诀(记住这三句,多线程稳了):

  1. 共享资源必上锁 —— pthread_mutex_lock / unlock
  2. 条件等待须循环 —— while(条件) + pthread_cond_wait
  3. 线程创建别忘等 —— pthread_join 或 pthread_detach

编译时别忘了:gcc -pthread!

掌握了今天的内容,你就有能力写出能充分利用多核CPU的高性能程序了。下一站:网络编程(Socket)——把单机的能力扩展到互联网上! 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

翎_鸢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值