目录
pthread 线程库
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 创建、调度、销毁等一系列管理。
TCB 结构体
在 pthread 中,使用 TCB(Thread Control Block,线程控制块)来存储 User Thread 的所有信息,TCB 的体量会比 PCB 小非常多。对应的 pthread 结构体如下:
// glibc/nptl/descr.h
/* Thread descriptor data structure. */
struct 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; // 线程的错误码
};
- 线程的合并:指等待某个线程结束后,主线程再继续执行资源回收。
- 线程的分离:指线程结束后会自动释放资源,不需要等待主线程回收。
- thread 参数:是一个 pthread_t 类型指针,用于存储 TID。
- attr 参数:是一个 pthread_attr_t 类型指针,用于指定线程的属性,通常为 NULL。
- start_routine 参数:线程入口函数,是一个 void* 类型函数指针(或直接使用函数名)。线程入口函数必须是一个 static 静态函数或全局函数,因为 pthread 会把线程入口函数的返回值传递到 pthread_join() 中,所以需要能够找到它。
- arg 参数:线程参数,是一个 void* 类型参数。
- 函数返回值:
- 成功:返回 0;
- 失败:返回 -1;
线程的生命周期管理
线程的合并与分离
对于 User Thread 的生命周期管理,首先要明确线程合并和线程分离的概念。
线程的合并与分离是指在多线程程序中,对于已经创建的线程进行结束和回收资源的 2 种操作方式。
需要注意的是,线程的合并和分离操作都必须在目标线程执行结束之前进行,并且必须二选一,否则会导致内存泄露甚至崩溃。
pthread_create() 创建线程
函数作用:用于创建一条新的对等线程,并指定线程的入口函数和参数。pthread 库就会为 User Thread 分配 TCB、PC(程序计数器)、Registers(寄存器)和 Stack(栈)等资源。并将其加入到 Thread Queue 中等待执行。直到 User Thread 被调度到 CPU 时,开始执行线程入口函数。
函数原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
- 1
- 2
pthread_join() 合并线程
函数作用:执行线程合并。阻塞当前的主线程,直到指定线程执行结束,然后获得线程的执行结果,并释放线程的资源。
函数原型:
- thread 参数:指定等待的 TID。
- retval:是一个指向指针的指针类型,用于存储线程的结果返回。
int pthread_join(pthread_t thread, void **retval);
- 1
pthread_exit() 线程主动退出
函数作用:线程主动终止自己,返回结果到 pthread_join()。需要注意的是,Main Thread 不应该调用 pthread_exit(),这样会退出整个 User Process。
函数原型:
- retval:是一个指针类型,用于存储退出码。如果不需要返回值,则设置为 NULL。
void pthread_exit(void *retval);
- 1
pthread_detach() 分离线程
函数作用:执行线程分离。将指定的线程标记为 “可分离的“,表示该线程在执行结束后会自动释放资源(由资源自动回收机制完成),无需等待主线程回收。另一方面,这也意味这主线程无法获得线程的返回值。
函数原型:
- thread 参数:指定 TID。
int pthread_detach(pthread_t thread);
- 1
线程的属性
可以在 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);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
线程分离属性
线程分离属性,即:将线程设定为 “可分离的"。
函数原型:
- attr:指定一个 pthread attribute 实例。
- detachstate:指定 attr 的分离属性:
- PTHREAD_CREATE_DETACHED:指示线程是分离的。
- PTHREAD_CREATE_JOINABLE:默认属性,指示线程是合并的,需要主线程调用 pthread_join() 来等待并释放资源。
pthread_attr_setdetachstat(pthread_attr_t *attr, int detachstate);
- 1
设定属性后不需要再通过 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 实例,通过 <sched.h> 中定义的函数进行初始化和操作。
int pthread_attr_setaffinity_np(pthread_attr_t *attr, size_t cpusetsize, const cpu_set_t *cpuset);
- 1
调度属性
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);
- 1
调度优先级,函数原型:只在 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);
- 1
- 2
- 3
- 4
- 5
- 6
调度继承权,函数原型:子线程是否继承父线程的调度算法和调度优先级。
- attr:指定一个 pthread attribute 实例。
- inheritsched:
- PTHREAD_EXPLICIT_SCHED:不继承。
- PTHREAD_INHERIT_SCHED:继承,默认。
int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);
- 1
多核平台并行编程示例
实践多线程的目的往往在于提升应用程序的执行性能,通常有并发和并行这 2 种方式:
-
并发程序:并发指在同一时间段内,多线程在同一个 CPU 上执行。并发程序不强制要求 CPU 具备多核计算能力,只要求多个线程在同一个 Core 上进行 “分时轮询” 处理,以此在宏观上实现多线程同时执行的效果。并发程序的执行通常是不确定的,这种不确定性来源于资源之间的相关依赖和竞态条件,可能导致执行的线程间相互等待(阻塞)。并发程序通常是有状态的(非幂等性)。
-
并行程序:并行指在同一时刻内,多线程在不同的 CPU core 上同时执行。并行程序会强制要求 CPU 具备多核计算能力,并行程序的每个执行模块在逻辑上都是独立的,即线程执行时可以独立地完成任务,从而做到同一时刻多个指令能够同时执行。并行程序通常是无状态的(幂等性)。
示例程序:
#define _GNU_SOURCE // 用于启用一些非标准的、GNU C 库扩展的特性,例如:<sched.h> 中的 CPU_ZERO 和 CPU_SET 函数。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#include <sched.h>
#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;
}
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
-
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 COMMAND
26031 24641 26032 26032 13 FF 10 - 50 0 0.0 FF Rl+ test1
26031 24641 26033 26033 13 FF 10 - 50 1 0.0 FF Rl+ test1
26031 24641 26034 26034 13 FF 10 - 50 0 0.0 FF Rl+ test1
26031 24641 26035 26035 13 FF 10 - 50 1 0.0 FF Rl+ test1
26031 24641 26036 26036 13 FF 10 - 50 0 0.0 FF Rl+ test1
26031 24641 26037 26037 13 FF 10 - 50 1 0.0 FF Rl+ test1
26031 24641 26038 26038 13 RR 20 - 60 0 33.3 RR Rl+ test1
26031 24641 26039 26039 13 RR 20 - 60 1 33.3 RR Rl+ test1
26031 24641 26040 26040 13 RR 20 - 60 0 33.3 RR Rl+ test1
26031 24641 26041 26041 13 RR 20 - 60 1 33.2 RR Rl+ test1
26031 24641 26042 26042 13 RR 20 - 60 0 33.2 RR Rl+ test1
26031 24641 26043 26043 13 RR 20 - 60 1 33.3 RR Rl+ test1
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 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);
- 1
pthread_mutex_lock()
函数作用:User Thread 用于获取互斥锁。如果互斥锁已被 Other User Thread 获得,则当前 User Thread 会阻塞。
函数原型:
- mutex 参数:pthread_mutex_t 类型指针,用于指定要获取的互斥锁。
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 1
pthread_mutex_unlock()
函数作用:User Thread 用于释放互斥锁,互斥锁重回可用状态。如果当前 User Thread 并没有锁,则该函数可能会产生未定义行为。
函数原型:
- mutex 参数:pthread_mutex_t 类型指针,用于指定要释放的互斥锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 1
条件变量(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);
- 1
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);
- 1
pthread_cond_signal()
函数作用:用于向等待条件变量的线程发送信号,唤醒其中的一个线程。
函数原型:
- cond 参数:pthread_cond_t 类型指针,用于指定要发送信号的条件变量。
int pthread_cond_signal(pthread_cond_t *cond);
- 1
pthread_cond_broadcast()
函数作用:用于向等待条件变量的所有线程发送信号,唤醒所有等待的线程。
函数原型:
- cond 参数:pthread_cond_t 类型指针,用于指定要发送信号的条件变量。
int pthread_cond_broadcast(pthread_cond_t *cond);
- 1
互斥锁和条件变量配合使用
当一个线程需要某个条件成立后才可以访问共享数据时。需要先锁定一个互斥锁,然后检查条件变量,如果条件不满足,则需要挂起并等待。
#include <pthread.h>
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;
}
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
线程非安全标准库函数
C 语言提供的大部分标准库函数都是线程安全的,但是也有几个常用函数是线程不安全的,也称为不可重入函数,原因是使用了某些全局或者静态变量。
我们知道,全局变量和静态变量分别对应内存中的全局变量区和静态存储区,这些区域都是可以跨线程访问的。在多线程环境中,这些数据如果在没有加锁的情况下并行读写,就会造成 Segmentfault / CoreDump 之类的问题。
- 不可重入函数汇总: