pthread学习

📅 2026/7/20 11:16:58
pthread学习
在 POSIX 标准pthreads中多线程编程的核心在于线程的生命周期管理和线程间的同步通信。下面编写了race_test.c程序并梳理了其中涉及的pthread核心函数及其作用、用法并总结了整体的使用范式。#define_GNU_SOURCE// 启用 pthread_cond_timedwait 和 GNU 扩展/* * * file race_test.c * brief * 竞态条件演示FD_CLOEXEC 的 TOCTOU 漏洞 * * description * 该程序故意触发“检查时与使用时”TOCTOU竞态条件。它演示了为什么在多线程环境中 * 通过 fcntl() 设置 FD_CLOEXEC 本质上是不安全的以及 O_CLOEXEC 是如何解决此问题的。 * */#includestdio.h#includestdlib.h#includeunistd.h#includefcntl.h#includepthread.h#includesys/wait.h#includeerrno.h#includestring.h#includetime.h// 用于获取系统时间#includelimits.h// 用于 PATH_MAX 常量/* * --- 同步原语 --- * 互斥锁保护共享状态变量。条件变量用于在漏洞出现的精确时刻人为地冻结线程 A * 以确保线程 B 恰好在竞态窗口期内执行 fork()。 */pthread_mutex_tmutexPTHREAD_MUTEX_INITIALIZER;pthread_cond_tcondPTHREAD_COND_INITIALIZER;/* * 共享状态标志 --- * 这些变量用于协调线程之间的执行流程。 */intfd_opened0;// 存储实际的文件描述符值intexec_done0;// 通知线程 A 恢复执行volatileintthread_a_error0;// 全局错误标志volatile 防止编译器优化// 目标路径的全局变量chartarget_dir[PATH_MAX];// 通过命令行传递的目录chartarget_file[PATH_MAX];// 要打开的文件的完整路径/** * brief 线程 A存在漏洞的打开者 * param 未使用的线程参数 * return NULL */void*thread_A_func(void*arg){/* * 抑制未使用参数警告。pthread_create 的回调函数强制要求 void* 签名 * 但我们不需要向此线程传递数据。 */(void)arg;intfd-1;pthread_mutex_lock(mutex);/* * 步骤 1打开文件不带 FD_CLOEXEC。 * 这创建了漏洞窗口。在这一确切时刻文件描述符被创建但缺少执行时关闭标志 * 使其容易泄漏。 */fdopen(target_file,O_CREAT|O_RDWR|O_TRUNC,0644);if(fd0){fprintf(stderr,[Thread A] open failed on %s: %s\n,target_file,strerror(errno));thread_a_error1;// 向主线程发送失败信号pthread_cond_signal(cond);// 防止主线程死锁pthread_mutex_unlock(mutex);returnNULL;}printf([Thread A] Opened file %s, fd %d. Waiting for Thread B to fork...\n,target_file,fd);fd_openedfd;// 动态传递真实的文件描述符pthread_cond_signal(cond);// 唤醒线程 B/* * 步骤 2在竞态窗口内冻结执行。 * 在此处阻塞直到 exec 完成或发生错误。这完美地模拟了操作系统调度器 * 在 open() 之后立即抢占线程 A 的情况。 */while(!exec_done!thread_a_error){pthread_cond_wait(cond,mutex);}pthread_mutex_unlock(mutex);/* * 步骤 3设置 FD_CLOEXEC为时已晚。 * 当执行到这一行时线程 B 已经完成了 fork 和 exec。子进程已经继承了这个 * 文件描述符使得这次 fcntl() 调用对于保护子进程来说完全无效。 */if(!thread_a_error){fcntl(fd,F_SETFD,FD_CLOEXEC);printf([Thread A] FD_CLOEXEC finally set on fd %d (but too late!).\n,fd);close(fd);// 清理本地资源}returnNULL;}/** * brief 打印命令行使用说明 * param 可执行文件的名称 */staticvoidprint_usage(constchar*prog_name){printf(Usage: %s target_directory\n\n,prog_name);printf(A robust demonstration of the TOCTOU race condition on FD_CLOEXEC.\n);printf(一个关于 FD_CLOEXEC TOCTOU 竞态条件的健壮演示程序。\n\n);printf(Options / 选项:\n);printf( -h, --help Show this help message and exit / 显示此帮助信息并退出\n);printf(\nArguments / 参数:\n);printf( target_directory The directory where test files will be created.\n);printf( 测试文件将被创建的目标目录。\n);printf(\nExample / 示例:\n);printf( %s ./test_workspace\n,prog_name);}/** * brief 主入口点 */intmain(intargc,char*argv[]){pthread_tthread_A;intstatus;pid_tpid;intret;//用于捕获 snprintf 返回值/* * --- 命令行参数解析 --- * 在处理其他参数之前优先检查帮助标志。 */if(argc2(strcmp(argv[1],-h)0||strcmp(argv[1],--help)0)){print_usage(argv[0]);returnEXIT_SUCCESS;}if(argc!2){fprintf(stderr,Error: Invalid number of arguments.\n);fprintf(stderr,错误参数数量无效。\n\n);print_usage(argv[0]);returnEXIT_FAILURE;}// 安全地将目录路径复制到全局缓冲区strncpy(target_dir,argv[1],sizeof(target_dir)-1);target_dir[sizeof(target_dir)-1]\0;/* * --- 安全的路径拼接 --- * 当格式化字符串可能超出目标缓冲区时GCC 的 -Wformat-truncation 会发出警告。 * snprintf() 返回“如果缓冲区足够大本应写入的字符数”。我们检查此返回值以防止 * 静默截断漏洞。 */retsnprintf(target_file,sizeof(target_file),%s/test_file.txt,target_dir);if(ret0||(size_t)retsizeof(target_file)){fprintf(stderr,[ERROR] Target file path too long or invalid: %s\n,target_dir);returnEXIT_FAILURE;}// Start Thread A / 启动线程 Aif(pthread_create(thread_A,NULL,thread_A_func,NULL)!0){perror(pthread_create failed);returnEXIT_FAILURE;}/* * 步骤 1带超时等待 --- * 防止线程 A 在发信号前崩溃导致无限挂起。 */pthread_mutex_lock(mutex);structtimespects;clock_gettime(CLOCK_REALTIME,ts);ts.tv_sec5;// 5 秒安全超时while(!fd_opened!thread_a_error){if(pthread_cond_timedwait(cond,mutex,ts)ETIMEDOUT){fprintf(stderr,[MAIN] Timeout waiting for Thread A! Aborting.\n);thread_a_error1;break;}}if(thread_a_error||fd_opened0){pthread_mutex_unlock(mutex);pthread_join(thread_A,NULL);returnEXIT_FAILURE;}inttarget_fdfd_opened;// Capture the real fd / 捕获真实的文件描述符pthread_mutex_unlock(mutex);/* * --- 步骤 2在竞态窗口期间 Fork --- * 因为线程 A 尚未调用 fcntl()新创建的文件描述符没有 FD_CLOEXEC 标志。 * fork() 会盲目地将所有缺少此标志的文件描述符复制到子进程中。 */printf([Thread B] File opened by A (fd%d). Forking now...\n,target_fd);pidfork();if(pid0){perror(fork failed);pthread_mutex_lock(mutex);exec_done1;pthread_cond_signal(cond);pthread_mutex_unlock(mutex);pthread_join(thread_A,NULL);returnEXIT_FAILURE;}if(pid0){/* * --- 子进程执行 --- * 动态格式化文件描述符字符串并传递目标目录。 */charfd_str[16];snprintf(fd_str,sizeof(fd_str),%d,target_fd);charchecker_path[PATH_MAX];retsnprintf(checker_path,sizeof(checker_path),%s/leak_checker,target_dir);if(ret0||(size_t)retsizeof(checker_path)){fprintf(stderr,[ERROR] Leak checker path too long.\n);exit(EXIT_FAILURE);}char*args[]{checker_path,fd_str,target_dir,NULL};execv(checker_path,args);perror(execv failed);// Only reached if exec fails / 仅在 exec 失败时执行exit(EXIT_FAILURE);}else{/* * 父进程继续执行 --- * 等待子进程完成验证然后唤醒线程 A让其完成现已毫无意义的fcntl() 调用。 */waitpid(pid,status,0);pthread_mutex_lock(mutex);exec_done1;pthread_cond_signal(cond);pthread_mutex_unlock(mutex);}/* * --- 资源清理 --- * 正确释放所有系统资源以确保在 Valgrind 中实现零泄漏。 */pthread_join(thread_A,NULL);// 等待线程结束pthread_mutex_destroy(mutex);// 销毁互斥锁pthread_cond_destroy(cond);// 销毁条件变量returnEXIT_SUCCESS;}一、 程序中涉及的 pthread 核心函数1. 线程的创建pthread_create()作用在当前的进程中创建一个新的线程并让它开始并发执行指定的函数。函数原型int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);用法解析thread输出参数用于保存新创建线程的唯一标识符ID。attr线程属性如栈大小、调度策略通常传NULL使用默认属性。start_routine线程的入口函数回调函数签名必须是void* func(void* arg)。arg传递给入口函数的参数以void*形式传入。程序中的体现在main函数中主线程调用此函数启动了thread_A_func将主程序的控制权一分为二实现了并发。2. 线程的等待与资源回收pthread_join()作用阻塞调用它的线程直到目标线程执行完毕终止。同时它会回收目标线程占用的系统资源。函数原型int pthread_join(pthread_t thread, void **retval);用法解析thread要等待的目标线程 ID。retval用于接收目标线程的返回值void**类型。如果不关心返回值可传NULL。程序中的体现在main函数的最后Resource Cleanup 阶段主线程调用pthread_join(thread_A, NULL)。这确保了在程序退出前线程 A 的所有清理工作如close(fd)都已完成防止了资源泄漏。3. 互斥锁的初始化与销毁作用互斥锁Mutex是保护共享资源如全局变量不被多个线程同时修改的同步机制。函数pthread_mutex_init()动态初始化一把锁。pthread_mutex_destroy()销毁锁释放相关资源。程序中的体现在代码中使用了宏PTHREAD_MUTEX_INITIALIZER进行了静态初始化这等同于在程序启动时自动完成了init操作。在程序结束时调用了pthread_mutex_destroy(mutex)进行清理。4. 互斥锁的加锁与解锁作用确保同一时刻只有一个线程能够进入“临界区”Critical Section执行代码。函数pthread_mutex_lock()尝试获取锁。如果锁已被其他线程持有当前线程会阻塞直到锁被释放。pthread_mutex_unlock()释放锁允许其他等待的线程获取。程序中的体现在thread_A_func和main中我们在修改共享状态如fd_opened,exec_done前后严格使用了lock和unlock成对包裹防止了多线程并发修改导致的数据竞争Race Condition。5. 条件变量的等待与唤醒作用用于线程间的“事件通知”机制。允许线程在某个条件不满足时挂起等待直到其他线程改变了条件并通知它。函数pthread_cond_wait()阻塞等待条件变量。注意调用此函数时必须已经持有了对应的互斥锁。它会自动释放锁并进入休眠被唤醒后会重新获取锁再返回。pthread_cond_signal()唤醒至少一个正在等待该条件变量的线程。pthread_cond_timedwait()带超时机制的等待防止线程永久死锁。程序中的体现这是本程序的灵魂。线程 A 调用pthread_cond_wait将自己冻结主线程在fork()后调用pthread_cond_signal唤醒线程 A。我们还使用了timedwait增加了程序的健壮性。二、 整体上应该如何正确使用 pthread在实际的工程开发中使用pthread应该遵循以下标准范式和最佳实践1. 遵循“谁创建谁负责”的资源回收原则创建线程后必须明确线程的归宿。如果主线程需要子线程的结果使用pthread_join等待其结束如果子线程是后台守护任务可以使用pthread_detach让其结束后自动回收资源避免内存泄漏。2. 严格成对使用同步原语锁的配对lock和unlock必须严格成对出现。如果在持有锁时发生了return或异常必须确保锁被释放否则会导致其他线程永久死锁。条件变量的配对cond_wait必须与mutex配合使用。唤醒操作signal通常也需要在持有锁的情况下进行以防止信号丢失。3. 防御性编程避免虚假唤醒Spurious Wakeups在使用pthread_cond_wait时绝对不能使用if判断条件而必须使用while循环。因为操作系统可能会在没有收到signal的情况下意外唤醒线程。正确写法while (!condition) { pthread_cond_wait(cond, mutex); }4. 极小化临界区Critical Section在持有互斥锁lock和unlock之间的代码块应该尽可能短。不要在锁内部执行耗时的操作如 I/O 读写、网络请求、复杂的计算或sleep否则会导致其他线程长时间阻塞严重降低程序的并发性能。5. 始终进行错误检查pthread系列函数在成功时返回0失败时返回错误码而不是-1。在生产级代码中应该检查pthread_create、pthread_mutex_init等函数的返回值以应对系统资源耗尽如线程数达到上限等极端情况。