引言
我们知道unix
进程中可以有多个线程,进程中的线程可以访问该进程的所有组成部分。并且CPU的调度单元就是线程。这就面临一个问题:当进程中的临界资源需要在多个线程中共享时,如何解决一致性问题?
本文将从线程的概念、线程的使用方式、unix
提供哪些方式解决一致性问题进行介绍,加深对线程的理解。
线程概念
线程的优点:
- 简化代码结构。比如在业务上为每种事件类型分配单独的处理线程,可以简化处理异步事件的代码。
- 提高程序的吞吐量以及响应时间。
- 对进程的共享资源访问更加的方便。
线程的资源:
每个线程除了共享进程的所有组成部分,也包含线程执行所必须信息:线程ID、一组寄存器、栈、调度优先级和策略、信号屏蔽字、error
变量以及线程私有数据。
线程的使用
线程ID
每一个进程有一个进程ID,每个线程也有一个线程ID。我们可以通过pthread_self
获取线程ID。
#include <pthread.h>
pthread_t pthread_self(void);
// 返回值:调用线程的线程ID
打印线程的ID,在程序调试阶段有时是非常有用的。
线程创建
#include <pthread.h>
int pthread_create(pthread_t *restrict tidp,
const pthread_attr_t *restrict attr,
void *(*start_rtn)(void*),
void *restrict arg);
// 返回值:若成功返回0 ,不成功,返回错误编码
tidp
。当线程创建成功后,tidp
会被设置为新创建子线程的线程ID。(《UNIX环境高级编程 第3版》 似乎描述错误了。)attr
参数用于设置线程的属性。比如:设置线程的栈大小(默认8MB),线程的调度策略及调度参数和优先级等。start_rtn
是新创建线程的运行开始地址。arg
是传给子线程的参数。如果需要向子线程传递两个以上的线程,需要将这些参数放到一个结构体中,然后将这个结构体地址传入(最好是堆内容,由子线程管理,释放)。
注:线程创建时并不能保证哪个线程会先执行:是新创建的线程,还是调用线程。
如下列示例就存在隐患:
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
void* my_thread(void * param)
{
int num = *param;
printf("param = %d\n",num);
return NULL;
}
int demo()
{
pthread_t tidp;
int num = 5;
if(pthread_create(&tidp,NULL,my_thread,&num) != 0)
{
printf("create pthread failed");
}
return 0;
}
分析:
demo
函数创建子线程成功后,子线程中的入参param
设置为demo
函数局部变量num
的地址。- 此时CPU优先调度
demo
,demo
函数执行完成,进行了栈回收。此时num
的地址空间可能就会被修改。 - CPU再次调用到子线程
my_thread
。此时访问num
地址的内容,就与预期不符。
简单的修改方式:传入num
的值。
线程终止
在进程控制章节,我们了解到在代码的任何地方调用exit
、_Exit
、_exit
,那么整个进程就会终止。那么是否可以在不停止进程的情况下,停止对应的进程呢。unix
提供了三种方式:
- 线程可以简单地从启动例程中返回,返回值是线程的退出码。
- 线程可以被同一进程中的其他线程取消。
- 线程调用
pthread_exit
。
这里着重介绍一下第二、三种方式:
#include <pthread.h>
int pthread_canncel(pthread_t tid);
// 返回值:若成功给,返回0;否则,返回错误编号
进程可以通过pthread_cancel
接口向指定同进程中的线程发起退出请求。但是它并不等待线程终止。而线程可以选择忽略此请求或控制如何被取消。
#include <pthread.h>
void pthrad_exit(void *rval_ptr);
int pthread_join(pthread_t thread, void **rval_ptr);
线程可以通过pthread_exit
接口退出线程,其中rval_ptr
是退出码,其它进程可以通过pthread_join
捕获退出码,但是调用线程在指定线程没有退出前,会一直处于阻塞状态。
线程清理处理程序
在进程环境章节,我们介绍到exit
函数在进程退出时,会先执行终止处理程序(类似C++中的析构函数),再清理标准I/O,atexit
提供了注册该处理程序的能力。类似的,线程也可以注册退出时调用的函数。
#include <pthread.h>
void pthread_cleanup_push(void (*rtn)(void*), void *arg);
void pthread_cleanup_pop(int execute);
注:线程清理处理程序只有两种情况下触发:
- 在调用
pthread_exit
主动退出时; - 响应其他线程的取消请求时。
即:线程正常从启动例程中return
退出是不会触发 线程清理处理程序。
线程分离
在进程环境章节,我们了解到子进程退出时,会在内存中保留退出状态,等待父进程通过waitpid
获取,否则会一直存在,成为僵尸进程,造成资源浪费。类似的,线程退出时,也会将终止状态保存着,等待其他进程调用pthread_jion
进行回收,否则同样也会造成资源浪费。
但是调用pthread_jion
可能会造成调用线程一直阻塞,与我们业务设计不符。若我们对线程退出状态不关心的话,可以将其进行线程分离。若线程已经被分离,线程的底层存储资源在线程终止时立即被回收。
#include <pthread.h>
int pthread_detach(pthread_t tid);
一致性问题探讨
当多个线程共享同一块内存时,就需要考虑数据一致性问题。多线程访问共享内存的场景可以分为以下几个场景。
- 共享变量(比如全局变量),仅由一个线程访问,其他线程不会读取和修改。这种场景就不存在问题。
- 多线程对共享变量只存在读取操作,不会修改。这种场景不存在问题。
- 当多线程访问一个共享变量,并且其中有一个以上的线程可以修改变量。则存在一致性问题。
一致性问题存在的根因:修改全局变量的操作往往不是原子操作,存在多个存储器访问周期。当其它线程读取时,可能在其修改周期内访问,则会造成读取异常值。举个例子:
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
long num = 0x00000000;
void* my_thread(void * param)
{
printf("num = 0x%0lx\n",num);
return NULL;
}
int main()
{
pthread_t tidp;
if(pthread_create(&tidp,NULL,my_thread,NULL) != 0)
{
printf("create pthread failed");
}
num = 0xffffffff;
pthread_join(tidp,NULL);
return 0;
}
分析:
- 主进程修改
num
变量,可能存在需要两个存储器周期(num
正好分配在两个物理页中)。
a. 将第一个页中的num
低32bit 设置为0xffff
b. 将第二物理页中的num
高32bit设置为0xffff - 正如上节讨论的,CPU对线程的调用顺序是随机的,因此子线程在访问
num
变量时,可能是主线程刚刚更新一个物理页中的数据。此时子线程得到的值就是0x0000ffff。这是就出现了异常,num
的业务含义可能只有0和0xffffffff。但是此时子线程获取到0x0000ffff,则会造成程序异常。
注:若修改操作是原子操作,就不存在竞争问题。比如C++中的原子变量,就可以避免多线程访问的一致性问题。
C语言并没有原子变量,但是unix
也提供了多种方式,在多线程访问共享变量时,如何保持同步。比如互斥量、读写锁、条件变量、自旋锁、屏障。
互斥量
互斥量使用pthread_mutex_t
数据类型表示。常见接口如下:
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量可以通过上述接口进行初始化。也可以静态初始化,设置为常量PTHREAD_MUTEX_INITIALIZER。
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
其中,若不希望线程被阻塞,可以使用pthread_mutex_trylock
,互斥量若未被锁住,则返回0,并锁住互斥量;若互斥量已经被锁住,则返回EBUSY
。
注:若同一个线程,连续对互斥量加锁两次以上,线程自身则会陷入死锁。并且其它线程也无法再次获取到互斥量,导致整个业务进入死锁状态。
#include <pthread.h>
#include <time.h>
int pthread_mutex_timelock(pthread_mutex_t *mutex,
const struct timespec *restrict tsptr);
当pthread_mutex_timelock
尝试获取互斥量时,若互斥量已经被锁住,则进行阻塞。直到其它线程将互斥量释放,获取到互斥量。或达到超时,返回ETIMEDOUT
。(超时指愿意等待的绝对时间,即在时间X之前可以阻塞等待,而不是等待Y秒)这就存在一个问题,若系统的时间变更了,则会出现意料之外的情况。
读写锁
读写锁和互斥量类似,不过读写锁在一些场景下,提供了更高的并行性。那是因为读写锁的特性决定的,读写锁有三种状态:
- 读模式加锁状态。当处于该状态时,所有试图以读模式对它进行加锁的线程,都可以得到访问全。但是任何以写模式加锁的线程都会被阻塞。
- 写模式加锁状态。当处于该状态时,所有试图对这个锁加锁的线程都会被阻塞。
- 不加锁状态。任何加锁请求都可以满足。
注:针对第一种状态,若当前已经处于读模式加锁状态,下一个线程写模式获取锁,会被阻塞。并且后续以读模式获取锁的线程也会被阻塞。其目的是防止读模式锁长期占用。
由于读写锁的特性,非常适合共享变量读取次数远远大于修改的场景。
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
读写锁在使用之前必须初始化,在释放底层内存之前,必须要销毁。
#include <pthread.h>
#include <time.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); // 读模式获取锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); // 写模式获取锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); // 释放锁
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); // 读模式获取锁
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); // 写模式获取锁
int pthread_mutex_timerdlock(pthread_rwlock_t *restrict rwlock,
const struct timespec *restrict tsptr);
int pthread_mutex_timewrlock(pthread_rwlock_t *restrict rwlock,
const struct timespec *restrict tsptr);
条件变量
条件变量是线程可用的另一种同步机制,条件变量本身需要使用互斥量保护。因此两者需要一同使用。
pthread_cond_t
数据类型表示条件变量,它可以用两种方式进行初始化。
- 常量
PTHREAD_COND_INITAIALIZER
赋值给静态分配的条件变量 - 动态分配,再使用
pthread_cond_init
初始化
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t * restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutet_t *restrict mutex);
int pthread_cond_timewait(pthread_cond_t *restrict cond,
pthread_mutet_t *restrict mutex,
const struct timespec *restrict tsptr);
这里的互斥量是用于对条件的保护。调用者需要将锁住的互斥量传给函数,函数然后回自动把调用线程放到等待条件的线程列表上,对互斥量解锁,等待条件变量满足。将这个流程分步骤理解如下:
- 获取互斥量
- 将条件变量放到等待条件的线程列表上
- 解锁互斥量。其它线程可以获取互斥量
- 线程阻塞,等待条件满足
- 当条件满足时,线程会再次尝试获取互斥量。
pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;
//伪代码如下:
pthread_mutext_lock(&qlock);
pthread_cond_wait(&qready,&qlock);
/* 临界资源处理*/
pthread_mutext_unlock(&qlock);
通知条件已满足,有两个接口。
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
自旋锁
自旋锁与互斥量类似,但是它不是通过休眠使线程阻塞,而是在获取锁之前一致处于忙等待阻塞状态。
在CPU性能优化——“瑞士军刀“章节中,我们了解到上下文切换的概念。一旦线程阻塞进入休眠,再次运行到此线程时,需要将该线程的上下文恢复,这个切换的过程是比较耗时的。
而自旋锁的特性,决定了:若明确等待锁的时间小于上下文切换的损耗,则在性能上获得提升。因此自旋锁的使用场景有:
- 短时间锁定。当预计线程持有锁的时间非常短时,使用自旋锁可能更有效。因为自旋锁避免了线程切换的开销,在等待锁释放的过程中,线程仍然在运行。
- 多核处理器:在多核处理器上,如果锁被持有的时间很短,让等待的线程在另一个核心上自旋,可能比将其挂起和稍后重新调度更高效。
- 低延迟要求:在需要低延迟响应的环境中,自旋锁可以减少线程因等待锁而被挂起的时间,从而降低响应时间。
- 内核态同步:在操作系统内核中,自旋锁经常用于同步对共享资源的访问,因为内核通常不能承受线程切换带来的开销。
- 无锁数据结构:在实现无锁(lock-free)或无等待(wait-free)数据结构时,自旋锁可以作为辅助工具,帮助确保在修改数据结构时的一致性。
- 高性能计算:在高性能计算(HPC)应用中,为了减少同步开销,可能会使用自旋锁来同步对共享资源的访问。
- 频繁访问的共享资源:当共享资源被频繁访问,且每次访问的时间都很短时,自旋锁可以减少线程切换的次数,提高效率。
#include <pthread.h>
int pthread_spin_init(pthread_spinlock_t *lock, int psshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
屏障
屏障是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程达到某点,然后从该点继续执行。
#include <pthread.h>
int pthread_barrier_init(pthread_barrier_t * restrict barrier,
const pthread_barrierattr_t *restrict attr,
unsigned int count);
int pthread_barrier_destroy(pthread_barrier_t *barrier);
其中count参数指定,在允许所有线程继续运行前,必须达到屏障的线程数目。
#include <pthread.h>
int pthread_barrier_wait(pthread_barrier_t *barrier);
线程在调用pthread_barrier_wait
接口时,会进行屏障计数,若未满足条件,则会进入休眠状态。若该线程是最后一个调用pthread_barrier_wait
接口的线程,所有线程都会被唤醒。
总结
本文主要介绍了Unix环境下多线程编程的概念、使用方式以及如何解决一致性问题。
线程概念:
- 线程是进程内的一个执行流,具有自己的线程ID、寄存器、栈等资源,但与同进程的其他线程共享进程资源。
- 线程的优点包括简化代码结构、提高程序吞吐量和响应时间,以及对共享资源的便捷访问。
线程的使用:
- 线程的创建、终止、清理处理程序、分离等操作方法。
- 线程ID的获取和使用,以及线程创建时可能出现的隐患和解决方法。
一致性问题探讨:
- 当多个线程共享内存时,可能存在一致性问题,特别是在多个线程对共享变量进行读写操作时。
- 一致性问题的根源在于修改操作的原子性不足,可能导致读取到中间状态的数据。
同步机制:
- 互斥量(Mutex):用于保证同一时间只有一个线程访问共享资源。
- 读写锁(RWLock):适用于读多写少的场景,提供更高的并行性。
- 条件变量(Cond):与互斥量结合使用,用于线程间的条件等待和通知。
- 自旋锁(Spinlock):适用于短时间锁定场景,减少线程切换开销。
- 屏障(Barrier):用于协调多个线程的并行工作,使它们在某个点上同步。
若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途