临界区的概念
之前的实例中我们只尝试创建了1个线程来处理任务,接下来让我们来尝试创建多个线程。
不过,还是得先拓展一个概念——“临界区”
临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。当有线程进入临界区段时,其他线程或是进程必须等待(例如:bounded waiting 等待法),有一些同步的机制必须在临界区段的进入点与离开点实现,以确保这些共用资源是被互斥获得使用,例如:semaphore。
多个线程同时执行时很容易产生问题,这个问题集中在对共用资源的访问上。
根据函数在执行时是否会导致在临界区发生问题,可将函数的类型分为两类:
- 线程安全函数 (Thread-safe function)
- 非线程安全函数 (Thread-unsafe function)
线程安全函数在被多个线程同时调用时不会引发问题,而非线程安全函数在被多个线程调用时则会发生问题。
拓展:
无论是Linux还是Windwos,我们都无需去区分线程安全函数和非线程安全函数,因为这在设计非线程安全函数的同时,开发者们也设计了具有相同功能的线程安全函数。
线程安全函数的名称一般是在函数添加后缀_r ,但在编程中如果我们全以这种方式来书写函数表达式,那么将会变得十分麻烦,为此我们可以通过在声明头文件前定义_REENTRANT宏。
如果追求更加快捷的代码编写体验,可以在编译键入参数时加入- D_REENTRANT,而不在编写代码时去引用_REENTRANT宏。
模拟多线程任务
接下来,让我们模拟出一个场景把这个问题体现出来,下列为示例代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#define THREAD_NUM 100
void *thread_inc(void *arg);
void *thread_des(void *arg);
long num = 0;
int main(int argc, char *argv[])
{
pthread_t thread_id[THREAD_NUM];
int i;
for (i = 0; i < THREAD_NUM; i++)
{
if (i % 2)
pthread_create(&(thread_id[i]), NULL, thread_inc, NULL);
else
pthread_create(&(thread_id[i]), NULL, thread_des, NULL);
}
for (i = 0; i < THREAD_NUM; i++)
pthread_join(thread_id[i], NULL);
printf("result: %ld \n", num);
return 0;
}
void *thread_inc(void *arg)
{
for (int i = 0; i < 100000; i++)
num += 1;
return NULL;
}
void *thread_des(void *arg)
{
int i;
for (int i = 0; i < 100000; i++)
num -= 1;
return NULL;
}
运行结果:
很明显结果并不是实际想要的“0”值 ,并且该输出值是随时变化的。
那么是什么原因导致这样不符合实际的值的出现呢?
在这里列举一种情形:
当线程A发起对变量λ=98的访问时,线程B也发起了访问,那么此时线程A、B都拿到了λ=98的数值。线程对该数值进行 +1 计算后,得出了99,并向该资源变量发起更新请求,但此时线程B也做同样的操作,并且是以之前同样拿到的数值λ=98为基础,那么最终的结果便是A算出了λ=99,B算的也是λ=99,最后更新的数值也是99,而实际应是100。
总结来讲,造成这类问题的原因在于相同时间内对同一资源的访问、处理出现了“时差”,导致了最终结果与实际偏离。
明白了原因,这个问题就很好解决了,那就是要把正在同时访问的资源读、写权限做一个限制,将线程同步起来。
线程同步
对于线程的同步,需要依靠“互斥量”和“信号量”这2种概念定义。
互斥量
互斥量用以限制多个线程同时访问,主要解决线程同步访问的问题,是一种“锁”的机制。
而互斥量在pthread.h库中也有专门的函数,用以创建和销毁,让我们来看看他的函数结构:
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t * mutex , const pthread_mutexattr_t * attr);
int pthread_mutex_destroy(pthread_mutex_t * mutex);
//成功时返回 0 ,失败时返回其他值。
/* 参数定义
mutex: 创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址值。
attr: 传递即将创建的互斥量属性,没有需要指定的属性时可以传递NULL。
另外,如果不需要配置特定的互斥量属性,可以通过使用PTHREAD_MUTEX_INITIALIZER宏来进行初始化,示例如下:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
不过,最好还是使用pthread_mutex_init函数来初始化,因为宏在调试时很难定位报错点,同时pthread_mutex_init对互斥量属性的设置也更直观可控一点。
互斥量锁住和解锁
上面所说到的两个函数只用于创建和销毁,最关键的还是上锁和解锁这两个操作函数,他们的结构如下:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t * mutex);
int pthread_mutex_unlock(pthread_mutex_t * mutex);
// 成功时返回 0 ,失败时返回其他值 。
进入临界区前需调用的函数是pthread_mutex_lock,若调用该函数时发现有其他线程已进入临界区,那么此时pthread_mutex_lock函数不会返回值,除非直到里面的线程调用pthreaed_mutex_unlock函数退出临界区后。
一般临界区的结构设计如下:
pthread_mutex_lock(&mutex);
//临界区的开始
//..........
//..........
//临界区的结束
pthread_mutex_unlock(&mutex);
特别注意,pthread_mutex_unlock()和pthread_mutex_lock()一般是成对的关系,如果线程退出临界区后没有对锁进行释放,那么其他等待进入临界区的线程将无法摆脱阻塞态,最终成为“死锁”状态。
接下来让我们尝试一下用互斥量来解决之前出现的问题吧
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#define THREAD_NUM 100
void *thread_inc(void *arg);
void *thread_des(void *arg);
long num = 0;
pthread_mutex_t mutex;
int main(int argc, char *argv[])
{
pthread_t thread_id[THREAD_NUM];
int i;
pthread_mutex_init(&mutex, NULL);
for (i = 0; i < THREAD_NUM; i++)
{
if (i % 2)
pthread_create(&(thread_id[i]), NULL, thread_inc, NULL);
else
pthread_create(&(thread_id[i]), NULL, thread_des, NULL);
}
for (i = 0; i < THREAD_NUM; i++)
pthread_join(thread_id[i], NULL);
printf("result: %ld \n", num);
pthread_mutex_destroy(&mutex);
return 0;
}
void *thread_inc(void *arg)
{
pthread_mutex_lock(&mutex);
// ↓临界区代码执行块
for (int i = 0; i < 100000; i++)
num += 1;
// ↑临界区代码执行块
pthread_mutex_unlock(&mutex);
return NULL;
}
void *thread_des(void *arg)
{
pthread_mutex_lock(&mutex);
for (int i = 0; i < 100000; i++)
{
// ↓临界区代码执行块
num -= 1;
// ↑临界区代码执行块
}
pthread_mutex_unlock(&mutex);
return NULL;
}
运行结果:
结果终于正确了~
需要特别注意的是,大家在设计锁的区域时一定要仔细考虑边界,确认出恰好需要“锁住”的那一个代码执行点和恰好可以结束的“释放”点,这样可以避免频繁调用“锁”和“解锁”操作,进而提高操作系统对于代码的执行效率。
信号量
信号量与互斥量相似,也是一种实现线程同步的方法。一般信号量用二进制0和1来表示,因此也称这种信号量为“二进制信号量”。
下面是信号量的创建及销毁方法:
#include <semaphore.h>
int sem_init(sem_t * sem , int pshared, unsigned int value);
int sem_destroy(sem_ t * sem);
//成功时返回0,失败时返回其他值
/* 参数含义
sem: 创建信号量时传递保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值。
pshared: 传递0时,创建只允许1个进程内部使用的信号量。传递其他值时,创建可由多个进程共享的信号量。
value: 指定新创建的信号量初始值。
*/
与互斥量一样,有“锁”和“解锁”函数
#include <semaphore.h>
int sem_post(sem_ t * sem);
int sem_wait(sem_t * sem);
//成功时返回0,失败时返回其他值。
/* 参数含义
sem: 传递保存信号量读取值的变量地址值,传递给sem_post时信号量增1,传递给sem_wait信号量减1。
*/
调用sem_init函数时,操作系统会创建信号量对象,并初始化好信号量值。在调用sem_post函数时该值+1,调用sem_wait函数时该值-1。
当线程调用sem_wait函数使信号量的值为0时,该线程将进入阻塞状态,若此时有其他线程调用sem_post函数,那么之前阻塞的线程将可以脱离阻塞态并进入到临界区。
信号量的临界区结构一般如下(假设信号量初始值为1):
sem_wait(&sem); //进入到临界区后信号量为0
// 临界区的开始
// ..........
// ..........
// 临界区的结束
sem_post(&sem); // 信号量变为1
信号量一般用以解决线程任务中具有强顺序的同步问题