十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux多线程编程核心:pthread创建、同步与线程池实战

Linux多线程编程核心:pthread创建、同步与线程池实战 1. 从“线程”到“pthread”为什么Linux开发者绕不开它如果你在Linux环境下写过C/C程序尤其是涉及到并发、高性能计算或者网络服务那你大概率听说过或者用过pthread。它不是一个新潮的技术但却是Linux系统编程中一块基石级别的存在。简单来说pthread是POSIX线程Portable Operating System Interface Threads的简称它定义了一套标准化的API让开发者能够在遵循POSIX标准的操作系统比如Linux、macOS、各种BSD上创建和管理线程。为什么它如此重要因为在现代计算中单核性能的提升已经遇到瓶颈多核、众核成为主流。想让程序跑得更快更好地利用多核CPU多线程编程是必经之路。而pthread就是Linux世界里进行多线程编程最基础、最直接的工具。它不像Java的Thread类或者Python的threading模块那样封装了高级语义pthread提供的是一套相对底层的C语言接口这让你能更精细地控制线程的行为理解线程背后的机制但同时也意味着你需要处理更多的细节比如同步、互斥、资源管理。可以说掌握了pthread你才算真正理解了Linux并发编程的底层逻辑无论是去学习更上层的并发框架如C的std::thread还是排查复杂的并发Bug都会游刃有余。2. pthread核心三板斧创建、等待与分离要玩转pthread首先得掌握它的三个最基本也是最核心的操作创建线程、等待线程结束、以及设置线程分离。这三个操作构成了线程生命周期的管理基础。2.1 线程创建pthread_create的细节与陷阱创建线程的函数是pthread_create它的原型看起来有点复杂int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);我们来拆解一下每个参数thread: 这是一个输出参数。函数成功返回后这里会被填入新创建线程的ID一个pthread_t类型的值。这个ID是系统内标识该线程的唯一句柄后续很多操作如等待、取消都需要用到它。attr: 线程属性指针。可以在这里设置线程的栈大小、调度策略、分离状态等。如果传入NULL则使用默认属性。对于初学者大部分时候用NULL就够了但深入优化时这个参数很重要。start_routine: 线程的入口函数。这是一个函数指针指向的函数必须符合void *func(void *arg)的格式。线程启动后就会从这个函数开始执行。arg: 传递给线程入口函数的参数。它是一个void*指针意味着你可以传递任何类型的数据的地址在线程函数内部再转换回原来的类型。一个最简单的创建示例#include pthread.h #include stdio.h #include unistd.h void* print_hello(void* arg) { int thread_num *(int*)arg; // 将void*参数转换回int* printf(Hello from thread %d\n, thread_num); sleep(1); return NULL; } int main() { pthread_t tid; int num 42; int ret pthread_create(tid, NULL, print_hello, num); if (ret ! 0) { perror(pthread_create failed); return 1; } // 主线程继续执行其他任务... sleep(2); // 等待一下子线程执行完 return 0; }注意这里有一个初学者极易踩的坑参数的生命周期。在上面的例子中我们把局部变量num的地址传给了新线程。这在新线程立即执行且主线程等待的情况下是安全的。但如果主线程在子线程使用num之前就返回了main函数结束那么num所在栈帧被回收子线程访问的就是一个非法地址会导致未定义行为通常是段错误。安全的做法是动态分配内存malloc来传递参数并在线程函数中负责释放或者使用同步机制确保主线程等待。2.2 线程汇合为什么需要pthread_join线程创建后就和主线程并行执行了。但很多时候我们需要等待一个线程完成它的工作并获取它的结果就像进程中的wait()系统调用一样。这个操作就是pthread_join。int pthread_join(pthread_t thread, void **retval);thread: 要等待的线程ID。retval: 这是一个二级指针用于获取线程入口函数的返回值。如果你不关心返回值可以传NULL。pthread_join主要做两件事阻塞等待调用pthread_join的线程通常是主线程会一直阻塞直到指定的线程终止。资源清理线程终止后系统并不会立即释放该线程占用的所有资源如栈内存、线程描述符。pthread_join会负责回收这些资源将线程置于“已终止且已清理”的状态。如果一个线程没有被join也没有被设置为分离状态那么它终止后就会变成“僵尸线程”占用系统资源。// 接上面的例子在主线程中等待 void* thread_result; int join_ret pthread_join(tid, thread_result); if (join_ret ! 0) { perror(pthread_join failed); } // 此时可以安全地使用thread_result如果线程函数有返回的话2.3 线程分离pthread_detach的适用场景不是所有线程都需要我们显式地去join。有些线程我们创建后就让它“自生自灭”它完成工作后自行退出系统自动回收其资源。这种线程就是“分离线程”detached thread。有两种方式设置线程为分离状态创建时指定通过pthread_attr_t属性对象设置PTHREAD_CREATE_DETACHED属性。创建后分离调用pthread_detach(pthread_t thread)函数。一旦线程被分离就不能再对它调用pthread_join调用会失败。分离线程通常用于执行一些独立的后台任务比如日志写入、心跳检测、监控等。// 方式一创建后分离 pthread_t tid; pthread_create(tid, NULL, some_task, NULL); pthread_detach(tid); // 从此主线程不再管它也无法join它 // 方式二创建时即分离 pthread_attr_t attr; pthread_attr_init(attr); pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); pthread_create(tid, attr, some_task, NULL); pthread_attr_destroy(attr);实操心得我个人的习惯是除非明确知道某个线程是纯粹的后台守护任务否则在开发调试阶段先使用pthread_join。因为join能让你明确知道线程何时结束方便调试和资源管理。等到程序稳定确认某个线程确实不需要同步结果时再考虑改为分离状态。滥用分离线程可能会导致你失去对程序执行流的掌控尤其在程序异常退出时分离线程的状态难以追踪。3. 线程同步锁、条件变量与读写锁多线程编程最大的挑战来自于“共享数据”。当多个线程同时读写同一块内存时如果没有正确的同步就会导致数据竞争Data Race结果是不可预测的。pthread提供了几种核心的同步机制。3.1 互斥锁pthread_mutex_t互斥锁Mutex是最常用的同步工具用于保证同一时间只有一个线程能进入被保护的代码段临界区。基本使用流程初始化pthread_mutex_init(mutex, NULL)或 静态初始化pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER。加锁pthread_mutex_lock(mutex)。如果锁已被其他线程持有则调用线程阻塞。访问共享数据在临界区内安全地读写。解锁pthread_mutex_unlock(mutex)。销毁pthread_mutex_destroy(mutex)。pthread_mutex_t counter_lock PTHREAD_MUTEX_INITIALIZER; int shared_counter 0; void* increment(void* arg) { for (int i 0; i 100000; i) { pthread_mutex_lock(counter_lock); shared_counter; // 临界区 pthread_mutex_unlock(counter_lock); } return NULL; }没有锁保护两个线程同时执行shared_counter最终结果很可能小于200000因为操作不是原子的。进阶话题锁的属性pthread_mutex_init的第二个参数可以设置锁属性比如PTHREAD_MUTEX_NORMAL普通锁不进行死锁检测。同一个线程重复加锁会导致死锁。PTHREAD_MUTEX_ERRORCHECK检错锁。同一线程重复加锁会返回错误EDEADLK便于调试。PTHREAD_MUTEX_RECURSIVE递归锁。允许同一个线程对同一把锁多次加锁需要相同次数的解锁。适用于函数可能递归调用自身且需要锁保护的场景。PTHREAD_MUTEX_ADAPTIVE自适应锁可能会自旋spin一段时间再阻塞适用于锁持有时间很短的场景。踩坑实录死锁。这是使用互斥锁最常见的问题。典型场景是线程A持有锁L1试图获取锁L2同时线程B持有锁L2试图获取锁L1。两者互相等待程序挂起。避免死锁的黄金法则固定锁的获取顺序。如果所有线程都约定先获取锁L1再获取锁L2那么死锁就不会发生。在复杂场景下可以使用pthread_mutex_trylock进行尝试加锁如果失败则释放已持有的锁过段时间再重试。3.2 条件变量pthread_cond_t互斥锁解决了互斥访问的问题但解决不了“等待某个条件成立”的问题。比如消费者线程需要等待队列不为空才能消费。忙等待不断加锁检查条件会浪费CPU。这时就需要条件变量。条件变量总是与一个互斥锁配合使用。工作模式是“在某个条件上等待”或“通知等待该条件的线程”。核心APIpthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex): 原子地释放锁mutex并在条件cond上等待。被唤醒后会**重新获取锁mutex**再返回。pthread_cond_signal(pthread_cond_t *cond): 唤醒至少一个在该条件上等待的线程。pthread_cond_broadcast(pthread_cond_t *cond): 唤醒所有在该条件上等待的线程。经典的生产者-消费者模型示例pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond PTHREAD_COND_INITIALIZER; Queue queue; // 一个共享队列 // 消费者线程 void* consumer(void* arg) { while(1) { pthread_mutex_lock(lock); while (queue_is_empty(queue)) { // 必须用while防止虚假唤醒 pthread_cond_wait(cond, lock); // 等待条件队列不空 } // 此时锁已重新获得且队列肯定不空 Item item queue_pop(queue); pthread_mutex_unlock(lock); process_item(item); } } // 生产者线程 void* producer(void* arg) { while(1) { Item item produce_item(); pthread_mutex_lock(lock); queue_push(queue, item); pthread_mutex_unlock(lock); pthread_cond_signal(cond); // 通知等待的消费者 } }关键细节解析为什么pthread_cond_wait需要传入互斥锁为什么判断条件要用while而不是if原子性wait操作“释放锁”和“进入等待”必须是原子的。否则如果先释放锁再准备等待这中间生产者可能已经signal了这个signal就丢失了消费者将永远等待下去。虚假唤醒即使没有线程调用signal或broadcast等待的线程也可能被唤醒。这是POSIX标准允许的可能由于系统实现或硬件原因。用while循环重新检查条件可以确保被唤醒时条件确实满足这是编写健壮条件变量代码的必须遵守的范式。3.3 读写锁pthread_rwlock_t互斥锁是排他的无论读写。但在很多场景下数据读多写少。允许多个线程同时读但写时独占能极大提升并发性能。读写锁就是为此设计的。读锁(pthread_rwlock_rdlock): 共享锁。多个线程可以同时持有读锁。写锁(pthread_rwlock_wrlock): 独占锁。一旦有线程持有写锁其他线程的读锁或写锁请求都会阻塞。尝试加锁(pthread_rwlock_tryrdlock/pthread_rwlock_trywrlock): 非阻塞版本。解锁(pthread_rwlock_unlock): 释放锁。pthread_rwlock_t rwlock PTHREAD_RWLOCK_INITIALIZER; SharedConfig config; void reader_thread() { pthread_rwlock_rdlock(rwlock); // 多个reader可以同时进入这里读取config read_config(config); pthread_rwlock_unlock(rwlock); } void writer_thread() { pthread_rwlock_wrlock(rwlock); // 只有一个writer可以进入这里修改config update_config(config); pthread_rwlock_unlock(rwlock); }使用建议读写锁并非总是优于互斥锁。如果临界区很小或者写操作非常频繁读写锁内部维护状态的开销可能会抵消其带来的并发收益。在实际使用中需要根据“读/写”的比例和临界区大小来评估。一个经验法则是读操作比例超过90%时考虑使用读写锁可能带来收益。4. 线程安全与线程局部存储4.1 什么是线程安全函数一个函数被称为“线程安全”的当且仅当被多个线程并发调用时总能产生正确的结果。反之则是“线程不安全”的。常见的线程不安全函数特征使用静态/全局变量如strtok、gmtime、rand。这些函数内部使用静态缓冲区多次调用会覆盖之前的结果。返回指向静态内存的指针如ctime、getpwnam。返回的指针指向的内容可能被其他线程的调用覆盖。Linux的GLIBC库为很多不安全函数提供了线程安全版本通常以_rreentrant可重入结尾如strtok_r、gmtime_r、localtime_r。这些函数要求调用者提供自己的缓冲区。// 非线程安全 char *time_str ctime(now); // 返回指向静态数据的指针 // 线程安全 struct tm tm_buf; char str_buf[64]; localtime_r(now, tm_buf); // 结果存入调用者提供的tm_buf strftime(str_buf, sizeof(str_buf), %c, tm_buf);在编写多线程程序时查阅手册man 7 pthreads或具体函数手册确认函数的线程安全性是必须的步骤。4.2 线程局部存储pthread_key_t与__thread有时候我们需要一些变量是“全局”的方便访问但又希望每个线程有自己的独立副本互不干扰。这就是线程局部存储Thread-Local Storage, TLS。POSIX标准方式pthread_key_t这种方式比较灵活但稍显繁琐。需要创建键key然后每个线程通过这个键来存取自己的数据。pthread_key_t log_key; // 定义一个键 void init_log_system() { pthread_key_create(log_key, free); // 第二个参数是析构函数线程退出时自动调用以清理数据 } void write_log(const char* msg) { // 获取本线程关联的日志文件指针 FILE* log_fp pthread_getspecific(log_key); if (!log_fp) { // 第一次调用为本线程创建日志文件 char filename[64]; snprintf(filename, sizeof(filename), thread_%lu.log, (unsigned long)pthread_self()); log_fp fopen(filename, a); pthread_setspecific(log_key, log_fp); } fprintf(log_fp, %s\n, msg); }GCC/Clang扩展__thread修饰符对于基本数据类型intlong 指针等GCC和Clang提供了更简单的语法使用__thread或C11标准的_Thread_local修饰符。static __thread int thread_local_counter 0; // 每个线程都有一份独立的thread_local_counter初始化为0这种方式性能极高访问速度和全局变量几乎一样但只能用于PODPlain Old Data类型不能用于需要构造/析构的C类对象除非编译器支持thread_local。经验选择如果需要存储的是简单的整数、指针用__thread。如果需要存储复杂结构体并且需要在线程退出时自动清理如关闭文件、释放内存则使用pthread_key_t并指定析构函数。5. 线程的取消与清理线程并非只能自然退出从入口函数返回也可以被外部请求取消cancel。这是一个强大但危险的功能。5.1 取消点与取消状态线程取消不是立即发生的。目标线程需要运行到某个“取消点”Cancellation Point时取消请求才会被处理。常见的取消点包括显式调用pthread_testcancel()。阻塞的系统调用如read,write,sleep,select,pthread_cond_wait等。一些标准库函数如printf,fopen具体看实现。可以通过pthread_setcancelstate和pthread_setcanceltype来设置线程的取消行为和类型。取消状态PTHREAD_CANCEL_ENABLE默认可取消或PTHREAD_CANCEL_DISABLE不可取消。取消类型PTHREAD_CANCEL_DEFERRED默认延迟取消只在取消点检查取消请求。PTHREAD_CANCEL_ASYNCHRONOUS异步取消可以在任何时间点被取消极其危险容易导致资源泄露和状态不一致一般不推荐使用。5.2 清理处理程序pthread_cleanup_push/pthread_cleanup_pop这是线程取消机制中至关重要的一环。如果一个线程在持有锁、打开了文件或分配了内存时被取消这些资源将永远无法被释放导致泄露。清理处理程序就是用来解决这个问题的。它们必须成对使用像{和}一样。pthread_mutex_lock(shared_lock); // 注册清理函数参数是pthread_mutex_unlock和锁的地址 pthread_cleanup_push(pthread_mutex_unlock, shared_lock); // 执行一些可能被取消的操作比如一个可能阻塞的I/O perform_risky_operation(); // 这里可能是一个取消点 // 如果执行到这里说明没有被取消我们需要手动弹出清理函数并执行解锁 pthread_cleanup_pop(1); // 参数1表示执行清理函数 // 如果上面没有被取消这里等价于 pthread_mutex_unlock(shared_lock); // 另一种情况如果 perform_risky_operation 中被取消 // 那么系统会从栈中依次弹出并执行所有已注册的清理函数 // 从而确保 shared_lock 被解锁pthread_cleanup_pop的参数如果是0则只从清理栈中弹出函数而不执行它。这适用于你希望自己手动处理资源释放的情况。血的教训我曾经调试过一个服务器程序在高并发取消任务时偶尔死锁。排查了很久才发现是一个工作线程在持有数据库连接锁的情况下在read系统调用一个取消点处被取消而代码中没有使用清理处理程序来释放锁导致锁永远不被释放其他线程全部阻塞。从此以后在任何可能被取消的临界区代码段我都会习惯性地加上pthread_cleanup_push/pop保护。6. 线程属性与高级控制除了基本的创建、同步pthread还允许对线程进行精细控制这主要通过pthread_attr_t属性对象来实现。6.1 栈大小管理每个线程都有自己独立的栈空间。默认栈大小因系统而异通常几MB到10MB。对于深度递归函数或分配大型栈变量的线程可能需要调整栈大小。pthread_attr_t attr; size_t stack_size 16 * 1024 * 1024; // 16MB void *stack_addr NULL; // 通常让系统分配也可以自己指定地址 pthread_attr_init(attr); pthread_attr_setstacksize(attr, stack_size); // 设置栈大小 // pthread_attr_setstack(attr, stack_addr, stack_size); // 同时设置地址和大小 pthread_t tid; pthread_create(tid, attr, thread_func, NULL); pthread_attr_destroy(attr);注意设置过大的栈会浪费内存设置过小则可能导致栈溢出Segmentation Fault。栈溢出很难调试因为错误可能发生在任何地方。如果你怀疑线程栈溢出可以先用ulimit -s查看系统默认栈大小然后适当调大测试。6.2 调度策略与优先级Linux支持多种线程调度策略如SCHED_FIFO先进先出实时调度、SCHED_RR轮转实时调度、SCHED_OTHER默认的CFS完全公平调度。普通用户进程的线程通常只能使用SCHED_OTHER需要特殊权限CAP_SYS_NICE才能设置实时调度策略。设置优先级相对复杂需要结合调度策略。对于SCHED_OTHER其优先级nice值是影响但不绝对控制调度权重的。pthread_attr_t attr; struct sched_param param; pthread_attr_init(attr); // 设置调度策略为SCHED_FIFO需要权限 pthread_attr_setschedpolicy(attr, SCHED_FIFO); // 设置优先级对于实时策略1为最低99为最高 param.sched_priority 50; pthread_attr_setschedparam(attr, param); // 告诉系统我们使用自定义的调度属性而不是继承的 pthread_attr_setinheritsched(attr, PTHREAD_EXPLICIT_SCHED); pthread_create(tid, attr, realtime_thread, NULL);对于大多数应用使用默认的SCHED_OTHER调度策略即可。实时调度策略主要用于对响应时间有严格要求的特定场景如工业控制、音频处理使用不当会导致系统不稳定。6.3 线程与信号信号在多线程环境下的行为是另一个复杂话题。传统的Unix信号是发送给进程的但在多线程中信号可以发送给特定的线程。信号掩码每个线程有自己独立的信号掩码signal mask用于阻塞或放行某些信号。可以使用pthread_sigmask设置。信号处理整个进程共享同一套信号处理函数通过sigaction设置。但哪个线程来处理捕获的信号是不确定的。通常的做法是在主线程启动所有工作线程前阻塞所有信号然后创建一个专用的“信号处理线程”这个线程调用sigwait来同步等待并处理信号。这样可以将异步信号安全地转换为同步事件。// 主线程阻塞所有信号 sigset_t all_signals; sigfillset(all_signals); pthread_sigmask(SIG_SETMASK, all_signals, NULL); // 创建信号处理线程 pthread_t sig_thread; pthread_create(sig_thread, NULL, signal_handler_thread, NULL); // 工作线程将继承主线程的信号掩码因此也不会收到信号干扰 void* signal_handler_thread(void* arg) { sigset_t wait_set; sigemptyset(wait_set); sigaddset(wait_set, SIGTERM); sigaddset(wait_set, SIGINT); int sig; while (1) { sigwait(wait_set, sig); // 同步等待信号 switch(sig) { case SIGTERM: case SIGINT: // 执行优雅关机逻辑 set_shutdown_flag(); return NULL; } } }7. 实战构建一个简单的线程池理解了所有基础组件后我们可以将它们组合起来实现一个在服务器开发中极其常用的组件线程池。线程池避免了频繁创建和销毁线程的开销用一组预先创建好的“工人线程”来处理源源不断的“任务”。7.1 线程池的核心数据结构设计我们的简易线程池需要以下部分任务队列一个链表或环形缓冲区用于存放待处理的任务。每个任务包含一个函数指针和参数。线程数组一组工人线程。同步机制互斥锁保护任务队列的并发访问。条件变量当任务队列为空时工人线程在此等待当有新任务加入时主线程通知工人线程。另一个条件变量可选用于等待所有任务完成。关闭标志用于通知所有线程优雅退出。typedef struct task { void (*function)(void* arg); void* arg; struct task* next; } task_t; typedef struct thread_pool { pthread_mutex_t lock; // 保护整个池子的锁 pthread_cond_t cond; // 通知工人线程的条件变量 pthread_cond_t idle_cond; // 等待池子空闲的条件变量可选 task_t* task_head; // 任务队列头 task_t* task_tail; // 任务队列尾 pthread_t* threads; // 线程数组 int thread_count; int shutdown; // 关闭标志0运行1关闭 int task_count; // 当前任务数 } thread_pool_t;7.2 线程池的初始化与工人线程例程初始化创建指定数量的线程每个线程运行同一个工人函数。void* worker_thread(void* pool_arg) { thread_pool_t* pool (thread_pool_t*)pool_arg; while (1) { pthread_mutex_lock(pool-lock); // 等待条件有关闭请求或者有任务可做 while (pool-task_head NULL !pool-shutdown) { pthread_cond_wait(pool-cond, pool-lock); } // 如果有关闭请求且没有任务了就退出 if (pool-shutdown pool-task_head NULL) { pthread_mutex_unlock(pool-lock); pthread_exit(NULL); } // 取一个任务 task_t* task pool-task_head; if (task ! NULL) { pool-task_head task-next; if (pool-task_head NULL) { pool-task_tail NULL; } pool-task_count--; // 如果任务队列空了可以通知等待“空闲”的线程如果有的话 if (pool-task_count 0) { pthread_cond_signal(pool-idle_cond); } } pthread_mutex_unlock(pool-lock); // 执行任务在锁外执行避免长时间持有锁 if (task ! NULL) { task-function(task-arg); free(task-arg); // 假设arg是动态分配的 free(task); } } return NULL; }7.3 提交任务与优雅关闭提交任务将一个新任务放入队列并通知一个工人线程。int thread_pool_submit(thread_pool_t* pool, void (*func)(void*), void* arg) { if (pool-shutdown) return -1; task_t* new_task malloc(sizeof(task_t)); // ... 检查malloc是否成功 ... new_task-function func; new_task-arg arg; new_task-next NULL; pthread_mutex_lock(pool-lock); if (pool-task_tail NULL) { pool-task_head pool-task_tail new_task; } else { pool-task_tail-next new_task; pool-task_tail new_task; } pool-task_count; pthread_cond_signal(pool-cond); // 通知一个等待的工人 pthread_mutex_unlock(pool-lock); return 0; }优雅关闭设置关闭标志唤醒所有工人线程等待它们结束。void thread_pool_shutdown(thread_pool_t* pool) { pthread_mutex_lock(pool-lock); pool-shutdown 1; pthread_mutex_unlock(pool-lock); // 广播所有在cond上等待的工人线程让它们检查shutdown标志 pthread_cond_broadcast(pool-cond); // 等待所有线程退出 for (int i 0; i pool-thread_count; i) { pthread_join(pool-threads[i], NULL); } // 清理剩余任务如果有 task_t* task; while ((task pool-task_head) ! NULL) { pool-task_head task-next; free(task-arg); free(task); } // 销毁互斥锁和条件变量 pthread_mutex_destroy(pool-lock); pthread_cond_destroy(pool-cond); pthread_cond_destroy(pool-idle_cond); free(pool-threads); free(pool); }这个线程池模型虽然简单但涵盖了pthread中互斥锁、条件变量、线程创建/汇合等核心概念的实际应用。在实际项目中你可能会需要增加更多功能比如动态调整线程数、任务优先级、更复杂的关闭逻辑等。但万变不离其宗其底层同步机制依然是pthread提供的这些基础原语。理解并熟练运用它们是写出正确、高效、稳定多线程程序的基石。
返回列表