C语言多线程面试题全解析
深入理解线程创建、同步机制、死锁预防及内存一致性模型,掌握并发编程核心考点,助您从容应对大厂技术面试。
一、C语言多线程基础核心
1. 进程与线程的区别
在C语言面试中,这是一个必问的基础题。进程是资源分配的基本单位,拥有独立的内存空间;而线程是CPU调度的基本单位,共享进程的堆内存和全局变量,但拥有独立的栈空间和寄存器状态。线程的创建和切换开销远小于进程。
2. POSIX Threads (pthread)
Linux环境下,C语言多线程编程主要依赖POSIX线程库(pthread)。核心函数包括:pthread_create(创建线程)、pthread_join(等待线程结束)、pthread_exit(终止线程)。面试常考这些函数的返回值及错误处理。
3. 线程的生命周期
线程状态包括:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和终止(Terminated)。面试官可能会询问线程在阻塞状态下释放CPU资源的情况,以及pthread_join如何导致调用线程阻塞。
二、同步机制与互斥锁详解
互斥锁 (pthread_mutex_t)
互斥锁用于保护临界区,确保同一时刻只有一个线程访问共享资源。面试重点在于理解pthread_mutex_lock和pthread_mutex_unlock的原语操作,以及锁的粒度选择。
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void worker(void arg) {
pthread_mutex_lock(&mutex); // 加锁
// 临界区代码
printf("Thread ID: %lu\n", pthread_self());
pthread_mutex_unlock(&mutex); // 解锁
return NULL;
}
面试陷阱:如果忘记解锁,会导致死锁;如果在持有锁时调用阻塞函数(如scanf),会导致其他线程永久阻塞。此外,需了解互斥锁的类型:快速锁、递归锁、检错锁。
条件变量 (pthread_cond_t)
条件变量用于线程间的通信,通常与互斥锁配合使用。它允许线程在满足特定条件时挂起,直到其他线程通知。核心函数是pthread_cond_wait和pthread_cond_signal。
// 等待条件
pthread_mutex_lock(&mutex);
while (condition == false) {
pthread_cond_wait(&cond, &mutex); // 原子地解锁并等待
}
// 处理数据
condition = false;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
关键点:为什么pthread_cond_wait需要放在while循环中?因为存在“虚假唤醒”(Spurious Wakeup)现象,或者多个线程被唤醒但只有一个能处理数据的情况。必须重新检查条件。
读写锁 (pthread_rwlock_t)
当读操作远多于写操作时,读写锁能显著提高性能。它允许多个读者同时访问,但写者独占访问。面试中常问读写锁与互斥锁的性能对比。
pthread_rwlock_t rwlock;
pthread_rwlock_init(&rwlock, NULL);
// 读操作
pthread_rwlock_rdlock(&rwlock);
// 读取数据
pthread_rwlock_unlock(&rwlock);
// 写操作
pthread_rwlock_wrlock(&rwlock);
// 修改数据
pthread_rwlock_unlock(&rwlock);
注意:读写锁可能导致写者饥饿(Writer Starvation),即持续的读请求导致写者无法获得锁。实现时需考虑公平性策略。
三、死锁的产生与避免
1. 死锁的四个必要条件
面试必背知识点。死锁发生必须同时满足:互斥条件(资源独占)、请求与保持条件(持有资源等待新资源)、不剥夺条件(资源不可强制释放)、循环等待条件(线程形成环路)。
2. 死锁的检测与解除
操作系统层面可通过资源分配图检测死锁。在应用层,通常通过超时机制或定期检查来解除。例如,设置pthread_mutex_trylock,如果获取锁失败,则释放所有已持有锁并重试。
3. 避免死锁的策略
最常用的策略是破坏循环等待条件:规定线程必须按顺序获取锁(如先锁A再锁B,所有线程遵循此顺序)。此外,使用trylock配合退避算法也是一种有效手段。
| 死锁避免策略 | 实现方式 | 优缺点 |
|---|---|---|
| 顺序加锁 | 全局定义锁的获取顺序 | 简单有效,但可能降低并发度 |
| 超时重试 | 使用trylock并设置超时 | 灵活,但可能引发活锁(Livelock) |
| 锁粗化/细化 | 调整锁的粒度 | 细粒度提高并发,粗粒度减少开销 |
四、内存模型与原子操作
1. 内存可见性问题
由于CPU缓存和编译器优化,一个线程对共享变量的修改可能不会立即被其他线程看到。在C11标准之前,C语言缺乏标准的内存模型,依赖编译器扩展。C11引入了_Atomic和内存序(Memory Order)。
2. 原子操作 (Atomic Operations)
原子操作是不可中断的操作。C11提供了atomic_int等类型。面试常考:fetch_add、compare_exchange_weak(CAS操作)的实现原理。CAS是无锁编程的基础。
3. 内存序 (Memory Order)
理解memory_order_relaxed、memory_order_acquire和memory_order_release。Acquire-Release语义保证了 happens-before 关系,是构建高效无锁数据结构的关键。
五、高级实战与并发设计模式
在实际工程项目中,单纯使用锁往往性能不足。以下是几个高频考点:
线程池设计 (Thread Pool)
频繁创建和销毁线程开销巨大。线程池维护一组工作线程和一个任务队列。面试常要求手写简易线程池,涉及:生产者-消费者模型、任务队列的线程安全、线程优雅退出机制。
// 伪代码结构
struct ThreadPool {
pthread_t threads;
int thread_count;
struct TaskQueue queue;
pthread_mutex_t mutex;
pthread_cond_t cond;
int shutdown;
};
无锁编程 (Lock-Free Programming)
利用CAS指令实现数据结构的并发访问,避免锁带来的上下文切换和死锁风险。难点在于ABA问题。解决方案包括使用版本号(Tagged Pointers)或内存回收机制(RCU)。
线程局部存储 (TLS)
使用thread_local关键字(C11)或__thread(GCC扩展)声明变量。每个线程拥有该变量的独立副本,彻底避免竞争,但需注意内存开销和数据一致性成本。
FAQ:高频面试题深度解答
Q1: 为什么pthread_join会导致调用线程阻塞?
A: pthread_join的设计目的是让主线程或其他线程等待目标线程执行完毕,以便回收目标线程的资源(如栈空间、线程描述符等)并获取其退出状态。如果不调用join或detach,线程资源将一直存在,导致内存泄漏。调用线程进入等待状态,直到目标线程终止。
Q2: 自旋锁(Spinlock)和互斥锁(Mutex)的区别是什么?何时使用自旋锁?
A: 互斥锁在获取失败时会使线程进入睡眠状态,涉及上下文切换,开销较大;自旋锁在获取失败时会不断循环检查(忙等待),不释放CPU。自旋锁适用于临界区非常短、竞争不激烈的场景,以避免上下文切换的开销。但在多核CPU上,如果锁竞争严重,自旋锁会浪费大量CPU周期。
Q3: 如何避免“惊群效应”(Thundering Herd)?
A: 惊群效应指多个线程阻塞在同一个资源上(如accept),当资源可用时,所有线程都被唤醒,但只有一个能成功处理,其余继续休眠,造成CPU浪费。解决方法包括:使用互斥锁保护accept调用,或使用epoll的LT/ET模式配合合理的线程分发策略,确保只有一个线程处理新连接。