ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux多线程编程实战:pthread从入门到避坑

Linux多线程编程实战:pthread从入门到避坑 简介这份资源面向Linux系统开发初学者与需要巩固并发编程基础的工程师围绕多线程编程这一核心主题系统梳理线程引入背景、pthread接口用法及常见同步问题。内容涵盖线程与进程的开销对比、pthread_create与pthread_join等关键函数、互斥锁与条件变量等同步机制并延伸至线程局部存储与线程优先级调整帮助读者理解线程安全与竞态条件的成因。资源包为1个PDF文档约134KB篇幅精炼适合作为随查随用的学习笔记或课程参考资料。目前已有1282人学习下载说明其在Linux多线程入门领域具备一定参考价值。读者可从中获得从概念到示例代码的完整梳理借助文中示例理解线程创建、等待与并发调度的实际表现并掌握同步工具的使用场景为后续编写高效、稳定的多线程程序打下基础。1. 从一次 CPU 飙到 800% 的线上故障说起一台 8 核的 Linux 服务器top里某个进程的 CPU 占用冲到 800%业务接口全部超时。登上去一看进程里开了 64 个线程每个线程都在死循环里抢同一把互斥锁。这不是什么高深的性能问题就是最典型的多线程编程翻车现场——线程开得越多越好、锁加得越勤越安全这两个直觉在 Linux 上几乎全是错的。Linux 下的多线程编程本质是围绕pthread这套 POSIX 线程接口把任务拆成能并发执行的单元再用同步原语控制它们对共享资源的访问。它解决的是「单核跑不满、多核用不上」的问题适合做网络服务、数据处理、嵌入式采集这类需要同时处理多路任务的场景。这篇笔记不讲教科书概念而是从线程创建、同步、到排查死锁和性能调优把一条能落地的路径走完。如果你正在写 C/C 服务端程序或者被linux面试题里的多线程题卡住过下面的内容可以直接对照着敲。2. pthread 线程创建与参数传递从最小可运行例子开始2.1 为什么选 pthread 而不是 fork在 Linux 上做并发第一层选择就是多进程还是多线程。fork出来的进程有独立地址空间隔离性好但进程间通信要走管道、共享内存开销大pthread创建的线程共享同一份地址空间切换成本低数据交换直接读写全局变量就行。代价是共享带来了竞争必须自己管好同步。常见做法是CPU 密集型且任务之间几乎不共享数据的用多进程I/O 密集、需要频繁共享状态的用多线程。网络服务器基本都走多线程或线程池路线因为连接之间要共享缓存、连接池、配置这些状态。pthread是 POSIX 标准接口Linux 上的 glibc 实现了它。编译时要加-pthread这个参数不只是链接libpthread还会定义_REENTRANT宏影响一些头文件的行为漏掉它可能出玄学问题。2.2 最小可运行的多线程程序先看一个能跑起来的最小例子创建两个线程各自打印自己的编号。#include pthread.h #include stdio.h #include stdlib.h #include unistd.h // 线程函数签名固定void* 入参void* 返回 void* worker(void* arg) { int id *(int*)arg; // 把 void* 转回 int 指针再解引用 printf(thread %d running, tid%lu\n, id, (unsigned long)pthread_self()); sleep(1); // 模拟实际工作 return NULL; } int main(void) { pthread_t tids[2]; int ids[2] {1, 2}; for (int i 0; i 2; i) { // 第二个参数是线程属性NULL 表示默认属性 int ret pthread_create(tids[i], NULL, worker, ids[i]); if (ret ! 0) { fprintf(stderr, pthread_create failed: %d\n, ret); exit(1); } } for (int i 0; i 2; i) { pthread_join(tids[i], NULL); // 等待线程结束回收资源 } printf(all threads done\n); return 0; }编译命令gcc -Wall -g -pthread thread_demo.c -o thread_demo ./thread_demo逻辑说明pthread_create的四个参数分别是线程句柄指针、线程属性、线程函数、传给线程函数的参数。返回值是错误码而不是设置errno所以判断失败要看返回值不能只看errno。pthread_join阻塞等待指定线程结束同时回收它的资源不 join 也不 detach 的线程会变成僵尸线程占着栈空间不释放。参数说明ids数组必须保证生命周期覆盖线程运行期。上面把ids[i]传进去是安全的因为ids在main的栈上main会等到 join 完才返回。如果传的是循环里的局部变量地址线程还没读到它就已经被下一轮覆盖这就是经典的参数传递踩坑。2.3 参数传递的三种正确姿势传参看着简单实际最容易翻车。常见有三种做法第一种是传堆上分配的结构体指针线程负责释放。适合参数多、结构复杂的场景。typedef struct { int id; char name[32]; } task_t; void* worker(void* arg) { task_t* t (task_t*)arg; printf(task %d %s\n, t-id, t-name); free(t); // 谁分配谁释放这里由线程释放 return NULL; } // 创建时 task_t* t malloc(sizeof(task_t)); t-id i; snprintf(t-name, sizeof(t-name), job-%d, i); pthread_create(tid, NULL, worker, t);第二种是传值而不是传地址。如果参数能塞进一个指针大小直接把整数强转成void*传进去避免生命周期问题。pthread_create(tid, NULL, worker, (void*)(long)i); // 线程里 int id (int)(long)arg;第三种是传数组元素地址但必须保证数组生命周期覆盖线程。前面最小例子里就是这种前提是 join 在数组销毁之前。提示用(void*)(long)i这种传值方式时注意int和指针宽度可能不一致中间过一层long更稳。2.4 线程属性分离状态与栈大小默认创建的线程是 joinable 的必须有人 join 或者 detach否则资源不回收。如果线程跑完就不管了创建时直接设成分离状态更省事。pthread_attr_t attr; pthread_attr_init(attr); pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); pthread_create(tid, attr, worker, arg); pthread_attr_destroy(attr);栈大小也常需要调。默认栈一般是 8MB开几千个线程就是几十 GB 虚拟内存虽然实际不全部驻留但地址空间和内核调度压力都在。I/O 密集型线程可以把栈调到 256KB 甚至更小。pthread_attr_setstacksize(attr, 256 * 1024);嵌入式 Linux 项目里内存紧张这个参数基本必调。但栈调太小函数调用深或者有大局部数组时会栈溢出表现为段错误且位置飘忽很难查。我一般先按 512KB 起步压测没问题再往下调。3. 互斥锁、条件变量与信号量三种同步原语的选型与写法3.1 互斥锁保护临界区的最小模板多线程共享数据必须加锁最基础的是pthread_mutex_t。看一个计数器例子两个线程各加一百万次。#include pthread.h #include stdio.h static long counter 0; static pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; void* add(void* arg) { for (int i 0; i 1000000; i) { pthread_mutex_lock(lock); counter; // 临界区只有这一行 pthread_mutex_unlock(lock); } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(t1, NULL, add, NULL); pthread_create(t2, NULL, add, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(counter%ld\n, counter); // 正确结果 2000000 return 0; }逻辑说明PTHREAD_MUTEX_INITIALIZER是静态初始化适合全局锁不用手动 destroy。动态分配的锁要用pthread_mutex_init和pthread_mutex_destroy配对。临界区要尽可能小上面只包住counter如果把整个循环包进去两个线程就退化成串行多线程白开。参数说明pthread_mutex_lock阻塞直到拿到锁pthread_mutex_trylock拿不到立即返回EBUSYpthread_mutex_timedlock带超时。超时锁在排查死锁时很有用可以避免整个进程卡死。3.2 条件变量解决忙等待互斥锁只能保证互斥不能保证顺序。生产者消费者场景里消费者要等队列非空如果用轮询加锁检查CPU 全浪费在空转上。条件变量就是干这个的。#include pthread.h #include stdio.h #define CAP 8 static int queue[CAP]; static int head 0, tail 0, count 0; static pthread_mutex_t mtx PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t not_empty PTHREAD_COND_INITIALIZER; static pthread_cond_t not_full PTHREAD_COND_INITIALIZER; void* producer(void* arg) { for (int i 0; i 100; i) { pthread_mutex_lock(mtx); while (count CAP) { // 必须用 while 不是 if pthread_cond_wait(not_full, mtx); } queue[tail] i; tail (tail 1) % CAP; count; pthread_cond_signal(not_empty); // 唤醒一个消费者 pthread_mutex_unlock(mtx); } return NULL; } void* consumer(void* arg) { for (int i 0; i 100; i) { pthread_mutex_lock(mtx); while (count 0) { pthread_cond_wait(not_empty, mtx); } int v queue[head]; head (head 1) % CAP; count--; pthread_cond_signal(not_full); pthread_mutex_unlock(mtx); printf(consume %d\n, v); } return NULL; }逻辑说明pthread_cond_wait做三件事——释放互斥锁、阻塞等待、被唤醒后重新加锁。所以它必须在持有锁的情况下调用。用while而不是if是因为存在虚假唤醒被唤醒后条件不一定真的满足必须重新检查。参数说明pthread_cond_signal唤醒至少一个等待者pthread_cond_broadcast唤醒全部。单生产者单消费者用 signal 就够多消费者且条件变化影响所有等待者时用 broadcast否则可能唤醒错人导致其他线程饿死。3.3 信号量做计数同步信号量sem_t本质是一个带阻塞的计数器适合控制「同时最多 N 个线程访问某资源」这类场景比如连接池限流。#include semaphore.h static sem_t slots; // 初始化最多允许 4 个并发 sem_init(slots, 0, 4); // 第二个参数 0 表示线程间共享 void* worker(void* arg) { sem_wait(slots); // 计数减一减到 0 就阻塞 // 访问受限资源 do_something(); sem_post(slots); // 计数加一唤醒等待者 return NULL; }逻辑说明sem_wait是 P 操作sem_post是 V 操作。和互斥锁的区别在于互斥锁的「锁」只能被加锁者解锁信号量没有所有者概念任何线程都能 post。这个特性让它适合做事件通知和资源计数但不适合做严格的互斥。参数说明sem_init第二个参数为 0 表示线程间共享非 0 表示进程间共享需要放在共享内存里。sem_wait会阻塞sem_trywait不阻塞sem_timedwait带超时。3.4 三种原语怎么选原语适用场景是否有所有者典型误用互斥锁保护共享数据读写是临界区过大、忘记解锁条件变量等待某个条件成立配合锁使用用 if 代替 while信号量资源计数、限流否当互斥锁用导致逻辑混乱选型原则很简单要保护数据用互斥锁要等条件用条件变量要数资源用信号量。三者经常组合出现比如生产者消费者就是互斥锁加两个条件变量。4. 多线程程序的避坑与排查五个血泪教训4.1 死锁两个线程互相等对方的锁现象程序跑着跑着卡死gdbattach 上去看所有线程都在pthread_mutex_lock上阻塞CPU 占用为 0。原因线程 A 持有锁 1 等锁 2线程 B 持有锁 2 等锁 1形成环路等待。常见于一个函数里先锁 A 再锁 B另一个函数先锁 B 再锁 A。解决统一加锁顺序所有地方都按同一顺序获取多把锁。或者用pthread_mutex_trylock加超时拿不到就释放已持有的锁重试。排查时用gdb的thread apply all bt看每个线程的调用栈能直接定位到卡在哪两把锁上。4.2 数据竞争没加锁的共享变量现象计数器结果偶尔少一点或者结构体读到一半被改字段之间不自洽。压测时概率出现单次跑又复现不了。原因多个线程同时读写同一变量没有同步。counter看着是一行实际是读-改-写三步中间可能被切换。解决所有共享可写数据都要有明确的保护策略。简单计数用原子操作__atomic_fetch_add或 C11 的_Atomic复杂结构用互斥锁。排查用ThreadSanitizer编译时加-fsanitizethread运行时会直接报出竞争位置。gcc -fsanitizethread -g -pthread race.c -o race ./race4.3 线程参数生命周期错误现象线程里读到的参数是乱值或者段错误位置飘忽。多线程创建时尤其明显。原因把循环变量的地址传给线程循环继续跑变量被覆盖线程读到的已经不是当初的值。或者传了栈上局部变量的地址函数返回后栈被复用。解决传值就用(void*)(long)i传结构就malloc一份线程负责free。永远不要传「马上就会失效」的地址。这个坑我在嵌入式采集程序里踩过线程读到的通道号全是错的查了一下午。4.4 忘记 join 或 detach 导致资源泄漏现象长时间运行后内存持续增长/proc/pid/status里Threads数量只增不减。原因joinable 线程结束后资源不自动回收必须有人 join。如果创建后既没 join 也没 detach线程结构体和栈一直占着。解决明确每个线程的归属。需要拿返回值的用 join不需要的创建时就设PTHREAD_CREATE_DETACHED。线程池场景里 worker 线程一般 detach由池子统一管理生命周期。4.5 条件变量用 if 判断导致虚假唤醒现象消费者偶尔从空队列里取数据或者生产者往满队列里写数据错乱。原因pthread_cond_wait可能在没有 signal 的情况下被唤醒也可能被唤醒后条件又被别的线程改回去了。用if只检查一次就往下走条件不成立时逻辑就错了。解决永远用while包住pthread_cond_wait被唤醒后重新检查条件。这是 POSIX 标准明确要求的写法不是可选项。5. 线程池与性能验证把并发数调到合适的值5.1 手写一个最小线程池线程池的核心是预先创建一批 worker 线程任务来了丢进队列worker 循环取任务执行。这样避免频繁创建销毁线程的开销。#include pthread.h #include stdlib.h typedef struct task { void (*fn)(void*); void* arg; struct task* next; } task_t; typedef struct { pthread_mutex_t lock; pthread_cond_t cond; task_t* head; task_t* tail; int shutdown; pthread_t* workers; int nworkers; } pool_t; static void* worker_loop(void* arg) { pool_t* p (pool_t*)arg; for (;;) { pthread_mutex_lock(p-lock); while (p-head NULL !p-shutdown) { pthread_cond_wait(p-cond, p-lock); } if (p-shutdown p-head NULL) { pthread_mutex_unlock(p-lock); break; } task_t* t p-head; p-head t-next; if (p-head NULL) p-tail NULL; pthread_mutex_unlock(p-lock); t-fn(t-arg); // 在锁外执行任务避免阻塞其他 worker free(t); } return NULL; } void pool_submit(pool_t* p, void (*fn)(void*), void* arg) { task_t* t malloc(sizeof(task_t)); t-fn fn; t-arg arg; t-next NULL; pthread_mutex_lock(p-lock); if (p-tail) p-tail-next t; else p-head t; p-tail t; pthread_cond_signal(p-cond); pthread_mutex_unlock(p-lock); }逻辑说明worker 循环里取任务时持锁执行任务时释放锁。如果执行任务时还持锁整个池子就退化成单线程。shutdown标志配合条件变量让所有 worker 能优雅退出。参数说明nworkers一般设成 CPU 核数或核数的 1 到 2 倍。I/O 密集型可以多设CPU 密集型设多了反而增加切换开销。可以用sysconf(_SC_NPROCESSORS_ONLN)拿到核数。5.2 用 perf 和 time 验证并发效果写完多线程程序怎么知道它真的变快了最直接的是time命令看 wall clock 和 CPU time 的比值。time ./thread_demo如果 wall clock 明显小于 CPU time说明确实并行了。如果两者接近说明没并行起来可能锁竞争太严重或者任务本身串行。更细的用perf stat看上下文切换和 CPU 迁移perf stat -e context-switches,cpu-migrations,cache-misses ./thread_demo上下文切换次数过高说明锁竞争激烈或者线程数远超核数。cache-misses高说明多线程访问共享数据导致缓存失效这时候要考虑减少共享或者做数据分片。5.3 线程数到底设多少这是被问最多的问题没有万能答案但有判断方法。CPU 密集型任务线程数设成核数左右多了只会增加调度开销。I/O 密集型任务线程数可以设成核数的几倍因为线程大部分时间在等 I/O不占 CPU。经验公式是N 核数 * (1 等待时间 / 计算时间)。等待时间远大于计算时间时N 可以很大。但线程数不是越多越好每个线程有栈开销内核调度也有成本。我一般从核数起步压测时逐步加找到吞吐量不再上升甚至下降的拐点。验证方法固定任务总量改线程数测总耗时。画出来一般是个 U 形曲线最低点就是合适的值。这个测试比任何公式都靠谱因为实际瓶颈往往不在你以为的地方。5.4 一个容易忽略的细节CPU 亲和性多线程在多个核之间迁移会导致缓存失效对性能敏感的场景可以绑定 CPU 亲和性。#define _GNU_SOURCE #include sched.h cpu_set_t set; CPU_ZERO(set); CPU_SET(core_id, set); pthread_setaffinity_np(pthread_self(), sizeof(set), set);逻辑说明pthread_setaffinity_np是 Linux 特有的非标准接口把当前线程绑定到指定核。适合把关键线程固定住减少迁移开销。但绑定太死也可能导致负载不均一般只对少数关键线程做。参数说明CPU_SET里的核编号从 0 开始要确认机器实际的核数。容器环境里拿到的核编号可能和物理核不对应绑定前先看/proc/cpuinfo和 cgroup 限制。写多线程代码这些年我最大的习惯是任何共享数据先问一句「谁在写、谁在读、怎么同步」答不上来就先别写。多线程的 bug 大多不是不会用接口而是没想清楚数据流。希望帮到你。本文还有配套的精品资源点击获取
返回列表