文章目录
pthread(POSIX Threads)是一套符合 POSIX(Portable Operating System Interface,可移植操作系统接口)的 User Thread 操作 API 标准,定义了一组线程相关的函数(60 多个)和数据类型。pthread API 可以用于不同的操作系统上,因此被称为可移植的线程 API。
在版本较新的 Linux Kernel 中,pthread API 的具体实现是 NPTL(Native POSIX Thread Library)。为了方便描述,在后文中我们使用 pthread 来统称。
- 实现源码:https://github.com/lattera/glibc/blob/master/nptl/pthread_create.c
- 文档:https://docs.oracle.com/cd/E19253-01/819-7051/attrib-74380/index.html
pthread 线程库围绕 struct pthread 提供了一系列的接口,用于完成 User Thread 创建、调度、销毁等一系列管理。
在 pthread 中,使用 TCB(Thread Control Block,线程控制块)来存储 User Thread 的所有信息,TCB 的体量会比 PCB 小非常多。对应的 pthread 结构体如下:
// glibc/nptl/descr.hstruct pthread { struct pthread *self; // 指向自身的指针 struct __pthread_internal_list *thread_list; // 线程列表,指向线程列表的指针,用于实现线程池; void *(*start_routine)(void*); // 线程的入口函数,由 pthread_create() 函数传入; void *arg; // 线程的入口函数参数,由 pthread_create() 函数传入; void *result; // 线程的返回值,由线程的入口函数返回; pthread_attr_t *attr; // 线程的属性,包括栈保护区大小、调度策略等,由 pthread_create() 函数传入; pid_t tid; // 线程的唯一标识符,由 Kernel 分配; struct timespec *waiters; // 等待的时间戳 size_t guardsize; // 栈保护区大小 int sched_policy; // 调度策略 struct sched_param sched_params;// 调度参数 void *specific_1stblock; // 线程私有数据的第一个块 struct __pthread_internal_slist __cleanup_stack; // 清理函数栈 struct __pthread_mutex_s *mutex_list; // 线程持有的互斥锁列表 struct __pthread_cond_s *cond_list; // 线程等待的条件变量列表 unsigned int detach_state:2; // 线程分离状态,包括分离和未分离两种; unsigned int sched_priority:30; // 线程的调度优先级 unsigned int errno_val; // 线程的错误码};
线程的合并与分离
对于 User Thread 的生命周期管理,首先要明确线程合并和线程分离的概念。
线程的合并与分离是指在多线程程序中,对于已经创建的线程进行结束和回收资源的 2 种操作方式。
- 线程的合并:指等待某个线程结束后,主线程再继续执行资源回收。
- 线程的分离:指线程结束后会自动释放资源,不需要等待主线程回收。
需要注意的是,线程的合并和分离操作都必须在目标线程执行结束之前进行,并且必须二选一,否则会导致内存泄露甚至崩溃。
pthread_create() 创建线程
函数作用:用于创建一条新的对等线程,并指定线程的入口函数和参数。pthread 库就会为 User Thread 分配 TCB、PC(程序计数器)、Registers(寄存器)和 Stack(栈)等资源。并将其加入到 Thread Queue 中等待执行。直到 User Thread 被调度到 CPU 时,开始执行线程入口函数。
函数原型:
- thread 参数:是一个 pthread_t 类型指针,用于存储 TID。
- attr 参数:是一个 pthread_attr_t 类型指针,用于指定线程的属性,通常为 NULL。
- start_routine 参数:线程入口函数,是一个 void* 类型函数指针(或直接使用函数名)。线程入口函数必须是一个 static 静态函数或全局函数,因为 pthread 会把线程入口函数的返回值传递到 pthread_join() 中,所以需要能够找到它。
- arg 参数:线程参数,是一个 void* 类型参数。
- 函数返回值:
- 成功:返回 0;
- 失败:返回 -1;
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
pthread_join() 合并线程
函数作用:执行线程合并。阻塞当前的主线程,直到指定线程执行结束,然后获得线程的执行结果,并释放线程的资源。
函数原型:
- thread 参数:指定等待的 TID。
- retval:是一个指向指针的指针类型,用于存储线程的结果返回。
int pthread_join(pthread_t thread, void **retval);
pthread_exit() 线程主动退出
函数作用:线程主动终止自己,返回结果到 pthread_join()。需要注意的是,Main Thread 不应该调用 pthread_exit(),这样会退出整个 User Process。
函数原型:
- retval:是一个指针类型,用于存储退出码。如果不需要返回值,则设置为 NULL。
void pthread_exit(void *retval);
pthread_detach() 分离线程
函数作用:执行线程分离。将指定的线程标记为 “可分离的“,表示该线程在执行结束后会自动释放资源(由资源自动回收机制完成),无需等待主线程回收。另一方面,这也意味这主线程无法获得线程的返回值。
函数原型:
- thread 参数:指定 TID。
int pthread_detach(pthread_t thread);
可以在 pthread_create() 新建线程时,直接指定线程的属性,也可以更改已经存在的线程的属性,包括:
- 线程分离属性;
- LWP 绑定属性;
- CPU 亲和性属性;
- 调度属性;
- 等等。
// 定义一个 pthread attribute 实例。pthread_attr_t attr;// 初始化一个 pthread attribute 实例。int pthread_attr_init(pthread_attr_t *attr);// 清除一个 pthread attribute 实例。int pthread_attr_destory(pthread_attr_t *attr);
线程分离属性
线程分离属性,即:将线程设定为 “可分离的"。
函数原型:
- attr:指定一个 pthread attribute 实例。
- detachstate:指定 attr 的分离属性:
- PTHREAD_CREATE_DETACHED:指示线程是分离的。
- PTHREAD_CREATE_JOINABLE:默认属性,指示线程是合并的,需要主线程调用 pthread_join() 来等待并释放资源。
pthread_attr_setdetachstat(pthread_attr_t *attr, int detachstate);
设定属性后不需要再通过 pthread_detach() 重复设定。
LWP 绑定属性
POSIX 标准引入了 “线程竞争域“ 的概念,即:User Threads 对 CPU 资源发起竞争的范围,并要求至少要实现下列 2 种范围之一:
- PTHREAD_SCOPE_PROCESS:User Threads 在 User Process 范围内竞争 CPU 资源。
- PTHREAD_SCOPE_SYSTEM:User Threads 在 System 范围内竞争 CPU 资源。
相应的,pthread API 库也提供了 pthread_attr_setscope() 接口来设定 User Threads 的竞争范围。但是,实际上 Linux NPTL 只实现了 PTHREAD_SCOPE_SYSTEM 这一种方式。
具体而言就是 LWP(Light Weight Process)的实现。在还没有 pthread 线程库的早期版本的 Linux 中,只有 Kernel Thread 的概念,User Process 只能通过 kthread_crearte SCI(系统调用接口)来创建 Thread。但这种方式显然会存在 User Space(User Process)和 Kernel Space(Kernel Thread)之间频繁的切换。
为了解决这个问题,POSIX 标准引入了 User Thread 和 LWP 的概念,最早在 Solaris 操作系统中实现。之所以要同时引入 LWP 的目的是为了让实现 User Thread 的 pthread API 接口能够在不同的操作系统中保持良好的兼容性。
而 Linux NPTL 则将 LWP 作为 User Thread 和 Kernel Thread 之间建立映射关系的桥梁,并让 User Threads 能够竞争全局的 CPU 资源,以此来发挥多核处理器平台的并行优势。
当调用 pthread_create() 新建多个 User Threads 时,Kernel 会为这些 User Threads 创建少量的 LWPs,并建立 M:N 的映射关系。这个映射过程是由 Kernel 完成的,开发者无法手动干预。
CPU 亲和性属性
在 Kernel 中,LWP 同样作为可调度单元,与 kthread_create() 创建的 Kernel Thread 一般,可以被 Kernel Scheduler 识别并根据调度策略调度到不同的 CPU 上执行。
默认情况下,User Thread 依靠 LWP 的可调度能力,会被 Kernel 尽力而为的分配到多个不同的 CPU cores 上执行,以达到负载均衡。但这种分配是随机的,不保证 User Thread 最终在那个 Core 上执行。
相对的,可以通过修改 User Threads 的 CPU 亲和性属性让它们在指定的 cpuset 中竞争。
函数原型:
- attr:指定一个 pthread attribute 实例。
- cpusetsize:指示 cpuset 实例的大小。
- cpuset:指示 cpuset 实例,通过
中定义的函数进行初始化和操作。
int pthread_attr_setaffinity_np(pthread_attr_t *attr, size_t cpusetsize, const cpu_set_t *cpuset);
调度属性
User Thread 的调度属性有 3 类,分别是:调度算法、调度优先级、调度继承权。
调度算法,函数原型:
- attr:指定一个 pthread attribute 实例。
- policy:指定调度算法:
- SCHED_OTHER:Linux 私有,默认采用,用于非实时应用程序。
- SCHED_FIFO(先进先出):POSIX 标准,用于实时应用程序。
- SCHED_RR(轮询):POSIX 标准,用于实时应用程序。
int pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy);
调度优先级,函数原型:只在 SCHED_FIFO 和 SCHED_RR 等实时调度算法中生效,User Process 需要以 root 权限运行,且需要显式放弃父线程的继承权。
- attr:指定一个 pthread attribute 实例。
- param:指向了一个 sched_param 结构体,其中 sched_priority 字段用于指定优先值,范围 1~99。
struct sched_param {int sched_priority;char __opaque[__SCHED_PARAM_SIZE__]; };int pthread_attr_setschedparam(pthread_attr_t *attr, const struct sched_param *param);
调度继承权,函数原型:子线程是否继承父线程的调度算法和调度优先级。
- attr:指定一个 pthread attribute 实例。
- inheritsched:
- PTHREAD_EXPLICIT_SCHED:不继承。
- PTHREAD_INHERIT_SCHED:继承,默认。
int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);
实践多线程的目的往往在于提升应用程序的执行性能,通常有并发和并行这 2 种方式:
-
并发程序:并发指在同一时间段内,多线程在同一个 CPU 上执行。并发程序不强制要求 CPU 具备多核计算能力,只要求多个线程在同一个 Core 上进行 “分时轮询” 处理,以此在宏观上实现多线程同时执行的效果。并发程序的执行通常是不确定的,这种不确定性来源于资源之间的相关依赖和竞态条件,可能导致执行的线程间相互等待(阻塞)。并发程序通常是有状态的(非幂等性)。
-
并行程序:并行指在同一时刻内,多线程在不同的 CPU core 上同时执行。并行程序会强制要求 CPU 具备多核计算能力,并行程序的每个执行模块在逻辑上都是独立的,即线程执行时可以独立地完成任务,从而做到同一时刻多个指令能够同时执行。并行程序通常是无状态的(幂等性)。
示例程序:
#define _GNU_SOURCE // 用于启用一些非标准的、GNU C 库扩展的特性,例如: 中的 CPU_ZERO 和 CPU_SET 函数。 #include #include #include #include #include #define THREAD_COUNT 12 // 12 个对等线程void show_thread_sched_policy_and_cpu(int threadno){ int cpuid; int policy; struct sched_param param; cpuid = sched_getcpu(); pthread_getschedparam(pthread_self(), &policy, ¶m); printf("Thread %d is running on CPU %d, ", threadno, cpuid); switch (policy) { case SCHED_OTHER: printf("SCHED_OTHER\n", threadno); break; case SCHED_RR: printf("SCHDE_RR\n", threadno); break; case SCHED_FIFO: printf("SCHED_FIFO\n", threadno); break; default: printf("UNKNOWN\n"); }}void *thread_func(void *arg){ int i, j; long threadno = (long)arg; printf("thread %d start\n", threadno); sleep(1); show_thread_sched_policy_and_cpu(threadno); for (i = 0; i < 10; ++i) { // 适当调整执行时长 for (j = 0; j < 10000000000; ++j) { } } printf("thread %d exit\n", threadno); return NULL;}int main(int argc, char *argv[]){ long i; int cpuid; cpu_set_t cpuset; pthread_attr_t attr[THREAD_COUNT]; pthread_t pth[THREAD_COUNT]; struct sched_param param; // 初始化线程属性 for (i = 0; i < THREAD_COUNT; ++i) pthread_attr_init(&attr[i]); // 调度属性设置 for (i = 0; i < THREAD_COUNT / 2; ++i) { param.sched_priority = 10; pthread_attr_setschedpolicy(&attr[i], SCHED_FIFO); pthread_attr_setschedparam(&attr[i], ¶m); pthread_attr_setinheritsched(&attr[i], PTHREAD_EXPLICIT_SCHED); } for (i = THREAD_COUNT / 2; i < THREAD_COUNT; ++i) { param.sched_priority = 20; pthread_attr_setschedpolicy(&attr[i], SCHED_RR); pthread_attr_setschedparam(&attr[i], ¶m); pthread_attr_setinheritsched(&attr[i], PTHREAD_EXPLICIT_SCHED); } // CPU 亲和性属性设置,使用 cpuset(0,1)。 for (i = 0; i < THREAD_COUNT; ++i) { pthread_create(&pth[i], &attr[i], thread_func, (void *)i); CPU_ZERO(&cpuset); cpuid = i % 2; CPU_SET(cpuid, &cpuset); pthread_setaffinity_np(pth[i], sizeof(cpu_set_t), &cpuset); } for (i = 0; i < THREAD_COUNT; ++i) pthread_join(pth[i], NULL); // 清理线程属性 for (i = 0; i < THREAD_COUNT; ++i) pthread_attr_destroy(&attr[i]); return 0;}
-
CPU 调度亲和性效果。
-
User Thread 和 LWP(12 + 1)绑定关系与调度策略效果。
$ ps -eLo pid,ppid,tid,lwp,nlwp,class,rtprio,ni,pri,psr,pcpu,policy,stat,comm | awk '$7 !~ /-/{print $0}' PID PPID TID LWP NLWP CLS RTPRIO NI PRI PSR %CPU POL STAT COMMAND26031 24641 26032 26032 13 FF 10 - 50 0 0.0 FF Rl+ test126031 24641 26033 26033 13 FF 10 - 50 1 0.0 FF Rl+ test126031 24641 26034 26034 13 FF 10 - 50 0 0.0 FF Rl+ test126031 24641 26035 26035 13 FF 10 - 50 1 0.0 FF Rl+ test126031 24641 26036 26036 13 FF 10 - 50 0 0.0 FF Rl+ test126031 24641 26037 26037 13 FF 10 - 50 1 0.0 FF Rl+ test126031 24641 26038 26038 13 RR 20 - 60 0 33.3 RR Rl+ test126031 24641 26039 26039 13 RR 20 - 60 1 33.3 RR Rl+ test126031 24641 26040 26040 13 RR 20 - 60 0 33.3 RR Rl+ test126031 24641 26041 26041 13 RR 20 - 60 1 33.2 RR Rl+ test126031 24641 26042 26042 13 RR 20 - 60 0 33.2 RR Rl+ test126031 24641 26043 26043 13 RR 20 - 60 1 33.3 RR Rl+ test1
- PID:进程 ID,唯一标识一个进程。
- PPID:父进程 ID,标识当前进程的父进程。
- TID:线程 ID,标识一个线程,与 LWP ID 一致(一一对应)。
- LWP:LWP ID,是内核调度实体,多个 LWP 可以属于同一个进程,但它们的调度是相互独立的。
- NLWP:进程的 LWP 数。
- CLS:调度算法。
- RTPRIO:实时优先级,仅适用于实时调度策略。
- NI:Nice 值,越小表示越高的优先级。
- PRI:优先级,与 NI 的值相关。
- PSR:进程或线程所绑定的处理器编号。
- %CPU:进程或线程的 CPU 使用率。
- POL:进程或线程的调度策略。
- STAT:进程或线程的状态。
- COMMAND:进程或线程的命令名或可执行文件名。
多线程安全(Multi-Thread Safe),就是在多线程环境中,多个线程在同一时刻对同一份共享数据(Shared Resource,e.g. 寄存器、内存空间、全局变量、静态变量 etc.)进行写操作(读操作不会涉及线程安全的问题)时,不会出现数据不一致。
为了确保在多线程安全,就要确保数据的一致性,即:线程安全检查。多线程之间通过需要进行同步通信,以此来保证共享数据的一致性。
pthread 库提供了保证线程安全的方式:
- 互斥锁(Mutex):是一种线程安全机制,为共享数据加上一把锁,拥有锁的线程,才可以访问共享数据。以此保护共享数据不被多个线程同时访问。
- 条件变量(Condition Variable):是一种线程同步机制,用于判断线程是否满足了特定的竞争条件(Race Condition)。只有满足条件的线程,才可以获得互斥锁,以此来避免死锁的情况。
需要注意的是,线程安全检查的实现会带来一定的系统开销。
互斥锁(Mutex)
pthread_mutex_init()
函数作用:用于初始化一个互斥锁实体。
函数原型:
- mutex 参数:pthread_mutex_t 类型指针,用于指定要初始化的互斥锁。
- attr 参数:pthread_mutexattr_t 类型指针,用于指定互斥锁的属性,例如:递归锁、非递归锁等,通常为 NULL。
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
pthread_mutex_lock()
函数作用:User Thread 用于获取互斥锁。如果互斥锁已被 Other User Thread 获得,则当前 User Thread 会阻塞。
函数原型:
- mutex 参数:pthread_mutex_t 类型指针,用于指定要获取的互斥锁。
int pthread_mutex_lock(pthread_mutex_t *mutex);
pthread_mutex_unlock()
函数作用:User Thread 用于释放互斥锁,互斥锁重回可用状态。如果当前 User Thread 并没有锁,则该函数可能会产生未定义行为。
函数原型:
- mutex 参数:pthread_mutex_t 类型指针,用于指定要释放的互斥锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
条件变量(Condition Variable)
互斥锁和条件变量都是多线程同步的工具,但是它们的作用不同:
- 互斥:互斥锁可以保护共享资源的访问,防止多个线程同时修改共享资源,但是它无法告知其他线程何时可以安全地访问共享资源,有可能导致死锁的发生。
举例来说,存在全局变量 n(共享数据)被多线程访问。当 TA 获得锁后,在临界区中访问 n,且只有当 n > 0 时,才会释放锁。这意味着当 n == 0 时,TA 将永远不会释放锁,从而造成死锁。
那么解决死锁的方法,就是设定一个条件:只有当 n > 时,TA 才可以获得锁。而这个条件,就是多线程之间需要同步的信息。即:在多线程环境中,当一个线程需要等待某个条件成立时,才可以获得锁,那么应该使用条件变量来实现。
- 同步:pthread 条件变量提供了一种线程同步机制,当特定的事件发生时,它可以唤醒一个或多个在等待事件的线程,从而实现线程间的同步和协调。条件变量通常与互斥锁一起使用,以避免竞态条件和死锁的发生。
pthread_cond_init()
函数作用:用于初始化一个条件变量实体。
函数原型:
- cond 参数:pthread_cond_t 类型指针,用于指定要初始化的条件变量。
- attr 参数:pthread_condattr_t 类型指针,用于指定条件变量的属性,通常为 NULL。
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
pthread_cond_wait()
函数作用:线程用于等待某个条件变量满足。
- 当 T1 线程调用 pthread_cond_wait() 时,会自动地释放掉互斥锁,并阻塞线程,开始等待。
- 直到另一个 T2 线程调用了 pthread_cond_signal() 或 pthread_cond_broadcast(),以此来通知 T1 条件变量满足了。
- 然后 T1 pthread_cond_wait() 重新获取指定的互斥锁并返回。
函数原型:
- cond 参数:pthread_cond_t 类型指针,用于指定要等待的条件变量。
- mutex 参数:pthread_mutex_t 类型指针,用于指定要关联的互斥锁。在等待期间,线程将释放该互斥锁。
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
pthread_cond_signal()
函数作用:用于向等待条件变量的线程发送信号,唤醒其中的一个线程。
函数原型:
- cond 参数:pthread_cond_t 类型指针,用于指定要发送信号的条件变量。
int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_broadcast()
函数作用:用于向等待条件变量的所有线程发送信号,唤醒所有等待的线程。
函数原型:
- cond 参数:pthread_cond_t 类型指针,用于指定要发送信号的条件变量。
int pthread_cond_broadcast(pthread_cond_t *cond);
互斥锁和条件变量配合使用
当一个线程需要某个条件成立后才可以访问共享数据时。需要先锁定一个互斥锁,然后检查条件变量,如果条件不满足,则需要挂起并等待。
#include pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;pthread_cond_t cond = PTHREAD_COND_INITIALIZER;int data = 0; // 共享数据void *producer(void *arg){ for (int i = 0; i < 10; i++) { pthread_mutex_lock(&mutex); // 加锁 data++; // 修改共享数据 pthread_cond_signal(&cond); // 发送信号 pthread_mutex_unlock(&mutex); // 解锁 sleep(1); } pthread_exit(NULL);}void *consumer(void *arg){ while (1) { pthread_mutex_lock(&mutex); // 加锁 while (data == 0) { // 如果没有数据就等待信号 pthread_cond_wait(&cond, &mutex); } printf("data = %d\n", data); // 打印共享数据 data--; // 修改共享数据 pthread_mutex_unlock(&mutex); // 解锁 sleep(1); } pthread_exit(NULL);}int main(){ pthread_t tid1, tid2; pthread_create(&tid1, NULL, producer, NULL); pthread_create(&tid2, NULL, consumer, NULL); pthread_join(tid1, NULL); pthread_join(tid2, NULL); return 0;}
C 语言提供的大部分标准库函数都是线程安全的,但是也有几个常用函数是线程不安全的,也称为不可重入函数,原因是使用了某些全局或者静态变量。
我们知道,全局变量和静态变量分别对应内存中的全局变量区和静态存储区,这些区域都是可以跨线程访问的。在多线程环境中,这些数据如果在没有加锁的情况下并行读写,就会造成 Segmentfault / CoreDump 之类的问题。
- 不可重入函数汇总: