在多线程并发场景下,不同线程间的指令执行先后顺序是不确定的。对于临界资源的访问,需要互斥进行。
例:多线程并发,执行自增操作,若非互斥访问临界资源,则自增操作会存在覆盖问题
实现互斥访问临界资源的方式是锁机制和原子操作。
1、linux 的锁机制
当一个线程访问的时候,需要加上锁以防止另外的线程对它进行访问,实现资源的独占。在一个时刻只能有一个线程掌握某个互斥锁,拥有上锁状态的线程能够对共享资源进行操作。
1.1、互斥锁 mutex
特点:只要一个线程获取了锁,其他线程则不能获取,竞争失败的线程让出 cpu,陷入休眠。
pthread_mutex_t mutex; pthread_mutex_init(&mutex, NULL); pthread_mutex_lock(&mutex) pthread_mutex_unlock(&mutex) pthread_mutex_destroy(&mutex)
2.2、自旋锁 spinlock
特点:轮询忙等待,获取不到锁就一直等待。
自旋锁采用原地等待的方式解决资源冲突。也就是说,一个被争用的自旋锁使得请求它的线程在等待锁重新可用的期间进行自旋(cpu 空转),反复检测锁有没有被解开。
在单核 CPU 上,自旋锁需要抢占式的调度器,否则无法使用,自旋线程永远不会放弃 CPU。
pthread_spin_init(pthread_spinlock_t *, int pshared); // pshared: PTHREAD_PROCESS_SHARED:多进程共享,PTHREAD_PROCESS_PRIVATE:本进程使用 pthread_spin_lock(pthread_spinlock_t *); pthread_spin_trylock(pthread_spinlock_t *); pthread_spin_unlock(pthread_spinlock_t *); int pthread_spin_destroy(pthread_spinlock_t *);
自旋锁与互斥锁的区别
- 互斥锁加锁失败后,线程挂起,释放 CPU 给其他线程。缺点线程切换带来系统开销。
- 自旋锁适加锁失败后,线程忙等,一直占用 CPU 直到它拿到锁。缺点 cpu 空转浪费资源,适用于在短期间内进行轻量级的锁定。
1.3、读写锁 rwlock
读写锁适用于单写多读的情况。将操作分为读、写两种方式,读模式锁定时多个线程共享,写模式锁住时则单线程独占,所以又称作共享-独占锁。
- 写独占:写锁占用时,其他线程加读锁或者写锁时都会阻塞
- 读共享:读锁占用时,其他线程加写锁时会阻塞,加读锁会成功
读写锁的策略
- 强读同步:读锁优先,只要写锁没有占用那么就可以加读锁
- 强写同步:写锁优先,只能等到所有正在等待或者执行的写锁执行完成后才能加读锁
大部分读写锁的实现都采用的是强写同步策略,这样做的目的主要是为了避免写饥饿,在多读少写的情况下防止数据修改延迟过高,参考读者写者问题的写优先。
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); pthread_rwlock_destroy(&rwlock);
2、原子操作
2.1、嵌入汇编语法
C语言使用__asm__
声明一个内联汇编表达式,volatile
向 gcc 声明不允许对该内联汇编优化。
C语言嵌入汇编语法格式如下:
__asm__ volatile(Instruction List : Output : Input : Clobber/Modify);
Instruction List
:汇编指令序列,指令间使用分号;
或换行符\n
分开。指令中的操作数可以使用占位符,操作数占位符最多10个,:%0, %1, …, %9
Output
:输出Input
:输入Clobber/Modify
:通知 gcc 当前嵌入汇编语句可能会对哪些寄存器或内存进行修改
用嵌入汇编实现自增的原子操作
__asm__ volatile ( "lock; xaddl %2, %1;" // 指令1:lock; 指令2: xaddl, 操作数占位符:%1, %2 : "=a" (old) // 输出:结果放入通用寄存器eax : "m" (*value), "a" (add) // 输入:操作数1(内存),操作数2(寄存器eax) : "cc", "memory" // 编译方式,内存 );
2.2、无锁 CAS
Compare And Swap,比较后交换。CAS 是一种无锁的解决方案,也是一种基于乐观锁的操作,解决多了线程并行情况下使用锁造成性能损耗。
CAS 原子操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值一致,则将该值更新为新值。否则,不做任何操作。
// CAS 操作 if (V == A) { V == B; }
CAS 操作在操作系统底层 (x86) 中对应的是cmpxchg
汇编指令
// 简化后的内联汇编 __cmpxchg 函数接口 static inline unsigned long __cmpxchg(volatile void *ptr, unsigned long old, unsigned long new) { unsigned long prev; __asm__ __volatile__(LOCK_PREFIX "cmpxchgl %1,%2" : "=a"(prev) : "r"(new), "m"(*__xg(ptr)), "0"(old) : "memory"); return prev; return old; }
gcc 提供了两个函数接口支持 CAS 原子操作
bool __sync_bool_compare_and_swap (type *ptr, type oldval, type newval, ...); type __sync_val_compare_and_swap (type *ptr, type oldval, type newval, ...);
我们也可以使用这些函数接口,用 CAS 原子操作实现自增原子操作。
int tmp = *pcount; // cas 操作 if (__sync_bool_compare_and_swap(pcount, tmp, tmp + 1)) { // 成功更新 pcount 的值 ++i; }
2.3、参考
3、cpu affinity
cpu 的亲和性指的是将进程或线程绑定到指定 cpu 核上运行。
在 Linux 内核中,cpu affinity 与进程的 task_struct 结构体中的 cpus_allowed 位掩码有关。这个位掩码由 n 位组成,与系统中的 n 个逻辑处理器一一对应。如果设置了相应的位,则进程就会在相关的 cpu 上运行。位掩码全 1 是进程的缺省状态,表示进程可以任何 cpu 上运行,并根据需要在 cpu 间进行迁移。
3.1、为什么使用 cpu 亲和性
通常 Linux 内核可以负载均衡对进程进行调度。在特定场景下需要实现性能的优化:
- 有大量的计算要做
- 提高 Cache 命中率。多核场景下,每个 cpu 都有自己的缓存,进程调度可能导致 cpu cache 命中率降低。绑定 cpu 后,进程只会在指定的 cpu 运行,命中率提高
- 进程实时性要求高,保证实时进程长时间运行。
3.2、cpu affinity API
运行时查看配置信息:查看 cpu 的核心数
long sysconf(int name); /* 参数:name 的取值 _SC_NPROCESSORS_CONF:查看cpu的个数 _SC_NPROCESSORS_ONLN:查看正在使用的cpu个数 _SC_PAGESIZE:查看缓存内存页面的大小 _SC_PHYS_PAGES:查看内存的总页数 _SC_AVPHYS_PAGES:查看可以利用的总页数 _SC_LOGIN_NAME_MAX:查看最大登录名长度 _SC_HOST_NAME_MAX:查看最大主机名长度 _SC_OPEN_MAX:每个进程运行时打开的文件数目 _SC_CLK_TCK:查看每秒中跑过的运算速率 sysconf(_SC_PAGESIZE) * sysconf(_SC_PHYS_PAGES) :计算内存大小 */
绑定 cpu 的 API 接口
- 绑定 cpu,绑定 id 对应的进程或线程,运行在 mask 指定的 cpu上,
cpusetsize = sizeof(cpu_set_t)
- 获取 cpu 位掩码,并将掩码信息返回到 mask 指向的结构体
// 1、用户态进程绑定 cpu int sched_setaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask); int sched_getaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask); // 2、用户态线程绑定 cpu int pthread_setaffinity_np(pthread_t thread, size_t cpusetsize,const cpu_set_t *cpuset); int pthread_getaffinity_np(pthread_t thread, size_t cpusetsize,cpu_set_t *cpuset);
宏定义,操作 cpu 的位掩码。
// 对 cpu set 初始化,设置为空集。 void CPU_ZERO (cpu_set_t *set); // 将指定的 cpu 加入 cpu set 中 void CPU_SET (int cpu, cpu_set_t *set); // 将指定的 cpu 从 cpu set 中删除 void CPU_CLR (int cpu, cpu_set_t *set); // 如果 cpu 是 cpu set 的一员,则返回一个非零值(true),否则就返回零(false)。 int CPU_ISSET (int cpu, const cpu_set_t *set);
3.3、代码实现
#define _GNU_SOURCE #include <stdio.h> #include <unistd.h> #include <pthread.h> #include <sched.h> #include <sys/syscall.h> #define THREAD_SIZE 2 void process_affinity(int num) { // 系统调用,获取进程id pid_t self_id = syscall(__NR_gettid); cpu_set_t mask; CPU_ZERO(&mask); CPU_SET(self_id % num, &mask); // 设置 cpu 亲和性:进程绑定cpu sched_setaffinity(self_id, sizeof(mask), &mask); while(1); // htop 查看 cpu 占用情况 } void* pthread_affinity(void *arg) { // 获取线程id pthread_t self_id = pthread_self(); int num = *(int*) arg; cpu_set_t mask; CPU_ZERO(&mask); CPU_SET(self_id % num, &mask); // 设置 cpu 亲和性:线程绑定cpu pthread_setaffinity_np(pthread_self(), sizeof(mask), &mask); while(1); // htop 查看 cpu 占用情况 } int main() { // 显示 cpu 核心数 int num = sysconf(_SC_NPROCESSORS_CONF); printf("num: %d\n", num); #if 0 // 1、进程绑定cpu pid_t pid = 0; for (int i = 0;i < num/2; ++i) { pid = fork(); if (pid <= 0) { break; } } if (pid == 0) { process_affinity(num); } #else // 2、线程绑定cpu pthread_t threadid[THREAD_SIZE] = {0}; for (int i = 0; i < num/2; ++i) { pthread_create(&threadid[i], NULL, pthread_affinity, &num); } #endif while(1) usleep(1); }