【多线程】【线程 | 进程】多线程同步

多线程相当于一个并发1系统,不同线程间指令执行没有固定顺序。如果运行结果依赖于不同线程特定的执行顺序,这种多个线程竞争资源的情况会导致结果很难预料。最常见的解决竞争条件的方法是将原先分离的两个指令构成不可分割的一个原子操作,而其他任务不能插入到原子操作中。

  • 同步与互斥:
    1.互斥:一个公共资源同一时间只能被一个进程或线程使用,其他线程或进程不能使用;
    2.同步:多个线程或进程在运行中相互协调,按照预定的顺序运行。

多线程同步是指在一定的时间内只允许某一个线程访问某个资源,不允许其他线程访问该资源。可以通过互斥锁条件变量读写锁信号量来进行同步。


1. 互斥锁

互斥锁是一种简单的加锁的方法来控制对共享资源的访问,它是一个特殊的变量类型pthread_mutex_t,它有锁上(lock)和打开(unlock)两个状态。互斥锁一般被设置成全局变量。

打开的互斥锁可以由某个变量获得。一旦获得,这个互斥锁会锁上,伺候只有该线程有权打开,其他想要获得该互斥锁的线程,会等待直到互斥锁再次打开的时候。

互斥锁的使用过程中,主要有pthread_mutex_initpthread_mutex_destroypthread_mutex_lockpthread_mutex_unlock这几个函数,分别完成锁的初始化、锁的销毁、上锁和释放锁操作。

对锁的操作主要包括加锁pthread_mutex_lock()、解锁pthread_mutex_unlock()和测试加锁pthread_mutex_trylock()三个,代码如下:

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);

pthread_mutex_lock()和pthread_mutex_trylock()语义类似,不同的是:pthread_mutex_lock()为阻塞操作,在锁已经被占据时线程会被挂起等待;而pthread_mutex_trylock()为非阻塞操作,在锁已经被占据时返回EBUSY。

代码分析:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<pthread.h>
#include<Windows.h>

pthread_mutex_t mutex_x = PTHREAD_MUTEX_INITIALIZER;
int total_ticket_num = 20;

void *sell_ticket(void *arg)
{
	for (int i = 0; i < 20; i++)
	{
		pthread_mutex_lock(&mutex_x);
		if (total_ticket_num > 0)
		{
			Sleep(1000);
			printf("sell the %dth ticket", 20 - total_ticket_num + 1);
			total_ticket_num--;
		}
		pthread_mutex_unlock(&mutex_x);
	}
	return 0;
}

int main()
{
	int iRet;
	pthread_t tids[4];
	int i = 0;
	for (int i = 0; i < 4; i++)
	{
		int iRet = pthread_create(&tids[i], nullptr, &sell_ticket, nullptr);
		if (iRet) {
			printf("pthread_create error, iRet=%d\n", iRet);
			return iRet;
		}
	}

	Sleep(30000);
	void *retval;
	for (int i = 0; i < 4; i++)
	{
		iRet = pthread_join(tids[i], &retval);
		if (iRet) {
			printf("tid=%d join error, iRet=%d\n", tids[i], iRet);
			return iRet;
		}
		printf("retval=%ld\n", (long)retval);
	}
	return 0
}

程序中有全局的互斥锁,并且在线程执行的函数sell_ticket中,for循环每次对全局变量total_ticket_num操作前加锁,操作后解锁。
第一个执行pthread_mutex_lock()的线程会获得mutex_x,其它想要获得mutex_x的线程必须等待,直到第一个线程执行到pthread_mutex_unlock()释放mutex_x,才可以获得mutex_x,并继续执行线程。所以线程在pthread_mutex_lock()和pthread_mutex_unlock()之间操作时,不会被其它线程影响,就构成了一个原子操作。

1.2 死锁

所谓死锁是指多个线程因竞争资源而造成的一种相互等待的僵局。

1. 产生死锁的必要条件:

1.互斥条件:线程间互斥使用资源,一个资源只能被一个进程所占有,其他线程申请该资源必须等待当前线程释放资源;
2.不可剥夺条件:线程获得的资源在未使用完毕前,不会被其他线程强行夺走;
3.请求保持条件:线程先申请了一部分资源,再申请其他资源时当前资源不会被释放;
4.循环等待条件:存在一个线程资源的循环等待链。

2. 避免死锁:

1.加锁顺序:线程按照一定的顺序加锁;
2.加锁时限:线程尝试获锁所时有一定的等待时限,超过时限则放弃请求并释放自己占有的锁;
3.死锁检测:将线程请求锁的关系图记录在某种数据结构中(如map),当有线程请求锁失败时遍历该关系图查看是否死锁。

  • 银行家算法是一种用来防止死锁的算法:
    从当前状态出发,按照资源剩余量逐个检查各个进程需要申请的资源量,若申请量小于剩余量则可以分配,并且进程完成后归还所有资源,再次检查各个进程能否分配资源。若都能分配资源则找到了一个安全的资源分配序列,否则就是不安全的。

2.条件变量

互斥锁的缺点:如果线程正在等待数据内某个条件出现,它可能会重复对互斥对象锁定和解锁,每次都会检查共享数据结构以查找某个值,这种方式会造成时间和资源的浪费。

条件变量通过允许线程阻塞等待另一个线程发送信号的方法弥补互斥锁的不足,它常和互斥锁一起使用。使用时,条件变量被用来阻塞一个进程,当条件不满足时,线程往往解开响应的互斥锁并等待条件变化。一旦其他线程改变了条件变量,它将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的进程,这些线程将重新锁定互斥锁并重新测试条件是否满足。

条件变量相关函数:

  1. 创建函数原型:pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
    int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);
  2. 注销函数原型:int pthread_cond_destroy(pthread_cond_t *cond);
  3. 等待条件有两种方式:条件等待pthread_cond_wait()和计时等待pthread_cond_timedwait()
  4. 激发条件有两种方式:pthread_cond_singal()激活一个等待该条件的线程,而pthread_cond_broadcast()则激活所有等待线程。
  • pthread_cond_singal函数的作用是发送一个信号给另外一个正被条件变量阻塞的线程,使其脱离阻塞继续执行,存在多个等待线程时按入队顺序激活其第一个,因此pthread_cond_signal不会有“惊群现象”(惊群现象即每当有资源可用,所有的进程/线程都来竞争资源)。

  • 在pthread_cond_wait函数中有一个隐含的操作:线程因等待条件变量进入等待状态时,会隐式释放当前锁,使得另外一个线程可以改变该条件变量;当另一个线程通过pthread_cond_singal发送信号给阻塞线程时,又会自动上锁。

条件变量特别适用于多个线程等待某个条件的发生。如果不使用条件变量,那么每个线程就需要不断尝试获得互斥锁并检查条件是否发生,这样大大浪费了系统的资源。

代码分析:

《后台开发:核心技术与应用实践》Page_324:例9.14:“完美的出租车”

#include<stdio.h>
#include<stdlib.h>
#include<cstring>
#include<unistd.h>
#include<pthread.h>
#include<errno.h>
#include<iostream>
#include<pthread.h>
#include<windows.h>
using namespace std;

/*提示出租车到达的条件变量*/
pthread_cond_t taxiCond = PTHREAD_COND_INITIALIZER;
/*同步锁*/
pthread_mutex_t taxiMutex = PTHREAD_MUTEX_INITIALIZER;

int travelerCound = 0;

void * traveler_arrive(void * name)
{
	cout << "Traveler: " << (char*)name << " needs a taxi now!\n";
	pthread_mutex_lock(&taxiMutex);
	travelerCound++;
	pthread_cond_wait(&taxiCond, &taxiMutex);
	pthread_mutex_unlock(&taxiMutex);
	cout << "Traveler: " << (char*)name << " now got a taxi!\n";
	pthread_exit((void*)0);
}

void * taxi_arrive(void * name)
{
	cout << "Taxi: " << (char *)name << " arrives.\n";
	while (1)
	{
		pthread_mutex_lock(&taxiMutex);
		if (travelerCound > 0)
		{
			pthread_cond_signal(&taxiCond);
			pthread_mutex_unlock(&taxiMutex);
			travelerCound--;
			break;
		}
		pthread_mutex_unlock(&taxiMutex);
	}
	pthread_exit((void*)0);
}

int main()
{
	pthread_t tids[3];
	int iRet = pthread_create(&tids[0], nullptr, taxi_arrive, (void *)(" Jack "));
	if (iRet){
		printf("pthread_create error: iRet=%d\n", iRet);
		return iRet;
	}
	printf("Time passing by.\n");
	Sleep(1000);

	iRet = pthread_create(&tids[1], nullptr, traveler_arrive, (void*)(" Susan "));
	if (iRet) {
		printf("pthread_create error: iRet=%d\n", iRet);
		return iRet;
	}
	printf("Time passing by.\n");
	Sleep(1000);
	
	iRet = pthread_create(&tids[2], nullptr, taxi_arrive, (void*)(" Mike "));
	if (iRet) {
		printf("pthread_create error: iRet=%d\n", iRet);
		return iRet;
	}
	printf("Time pasing by.\n");
	Sleep(1000);

	void *retval;
	for (int i = 0; i < 3; i++)
	{
		iRet = pthread_join(tids[i], &retval);
		if (iRet) {
			printf("pthread_join error: iRet=%d\n", iRet);
			return iRet;
		}
		printf("retval=%ld\n", (long)retval);
	}
	return 0;
}

程序中模拟了出租车与乘客的到达情况,一共创建了3个线程,2个是出租车(调用出租车到达的函数),1个是乘客(调用乘客到达的函数)。

程序中有一个条件变量,用于提示出租车到达,还有一个同步锁,代码如下:

/*提示出租车到达的条件变量*/
pthread_cond_t taxiCond = PTHREAD_COND_INITIALIZER;
/*同步锁*/
pthread_mutex_t taxiMutex = PTHREAD_MUTEX_INITIALIZER;

3. 读写锁

  • 线程对资源的访问存在两种情况:
    1.写操作:访问必须是排他性的、独占的;
    2.读操作:访问可以是共享的,即可以有多个线程同时去访问某个资源。

所以读写锁比互斥锁具有更高的适用性与并行性,可以有多个线程同时占用读模式的读写锁,但只能有一个线程占用写模式的读写锁:
1.当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都将被阻塞;
2.当读写锁是读加锁状态时,其他试图获取读模式锁的线程可以得到访问权,但试图获取写模式锁的线程会被阻塞;
3.当读写锁是读状态加锁时,有其他线程试图以写模式加锁,那么读写锁会阻塞随后的读模式锁请求,以避免读模式锁长期占用导致写模式锁请求被长期阻塞。

  • 读写锁最适用于对数据结构的读操作多于写操作次数的场合,因为读模式锁定时可以共享,而写模式锁定时只能由某个线程独占资源,因为读写锁又称为共享-独占锁
强读者同步和强写者同步策略:

处理读者-写者问题的两种常见策略时强读者同步和强写者同步:

  1. 强读者同步中,只要写者当前没有进行写操作,读者就可以获得访问权限;
  2. 强写者同步中,必须等到所有写者操作完成后,读者操作才能执行。

读者往往需要最新的信息,一些实时性较高的系统可能会用到强写者同步策略,如航班订票系统;而图书馆查阅系统则采用强读者同步策略。

读写锁的数据类型是pthread_rwlock_t,常用操作函数原型如下:

int pthread_rwlock_rdlock(pthread_rwlock_t *rwptr);//获取读出锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwptr);//获取写入锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwptr);//释放读出或者写入锁

其中,获取锁的两个操作都是阻塞操作,即获取不到锁的话,那么调用线程不是立即返回而是阻塞执行。还有非阻塞获取读写锁的函数pthread_rwlock_tryrdlock()pthread_rwlock_trywrlock(),以非阻塞方式获取锁的时候,如果不能马上获取到,就会立即返回一个EBUSY错误提示,而不会进入睡眠等待。

代码分析:

《后台开发:核心技术与应用实践》Page_328:例9.15:“读写锁的使用”

#include<stdio.h>
#include<pthread.h>
#include<stdlib.h>
#include<unistd.h>
#include<Windows.h>
#define THREADNUM 5

pthread_rwlock_t rwlock;

void *readers(void *arg)
{
	pthread_rwlock_rdlock(&rwlock);
	printf("reader %ld got the lock\n", (long)arg);
	pthread_rwlock_unlock(&rwlock);
	pthread_exit((void*)0);
}

void *writers(void *arg)
{
	pthread_rwlock_wrlock(&rwlock);
	printf("writer %ld got the lock\n", (long)arg);
	pthread_rwlock_unlock(&rwlock);
	pthread_exit((void *)0);
}

int main(int argc,char **argv)
{
	int iRet, i;
	pthread_t writer_id, reader_id;
	pthread_attr_t attr;
	int nreadercount = 1, nwritercount = 1;
	iRet = pthread_rwlock_init(&rwlock, nullptr);
	if (iRet) {
		fprintf(stderr, "init lock failed\n");
		return iRet;
	}

	pthread_attr_init(&attr);
	pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
	for (int i = 0; i < THREADNUM; i++)
	{
		if (i % 3)
		{
			pthread_create(&reader_id, &attr, readers, (void*)nreadercount);
			printf("create reader %d\n", nreadercount);
		}
		else
		{
			pthread_create(&writer_id, &attr, writers, (void *)nwritercount);
			printf("create writer %d\n", nwritercount);
		}
	}
	Sleep(5000);
	return 0;
}

上例中定义了一个全局的读写锁。在main函数中,初始化了读写锁,创建了5个线程,其中有3个调用了readers函数,2个调用了writers函数。


4. 信号量

互斥锁只允许一个线程进入临界区2,而信号量允许多个线程同时进入临界区。信号量机制通过信号量的值控制可用资源的数量,线程访问共享资源前会申请一个信号量,如果信号量为0说明当前无可用资源,进入阻塞等待状态;如果信号量不为0说明有可用资源,则此线程占用一个资源并将对应信号量减1。

使用信号量需要包含头文件include<semaphore.h>,线程使用的基本信号量函数原型有:

int sem_init(sem_t *sem, int pshared, unsigned int value);//初始化
int sem_wait(sem_t *sem);	//以原子操作方式将信号量减1,成功时返回0
int sem_post(sem_t *sem);	//以原子操作方式将信号量加1,成功时返回0
int sem_destroy(sem_t *sem);	//对用完的信号量进行清理
代码分析:

《后台开发:核心技术与应用实践》Page_330:例9.16:“用信号量模拟窗口服务系统”

#include<pthread.h>
#include<semaphore.h>
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<Windows.h>
#define CUSTOMER_NUM 10

//将信号量定义为全局变量,方便多个线程共享。
sem_t sem;

void * get_service(void *thread_id)
{
//注意:立即保存thread_id的值,因为thread_id是对主线程中循环变量i的引用,它可能马上被修改。
	int customer_id = *((int *)thread_id);
	if (sem_wait(&sem) == 0) //sem_wait函数调用成功时返回0,失败返回-1.
	{
		Sleep(100);//服务时间:100ms
		printf("customer %d receive service ...\n", customer_id);
		sem_post(&sem);
	}
}

int main()
{
	sem_init(&sem, 0, 2);//初始化信号量,初始值为2,表示有两个顾客可以同时接受服务
	pthread_t customers[CUSTOMER_NUM];
	int i, iRet;
	for (i = 0; i < CUSTOMER_NUM; i++)
	{
		int customer_id = i;
		iRet = pthread_create(&customers[i], nullptr, get_service, &customer_id);
		if (iRet) {
			perror("pthread_create");
			return iRet;
		}
		else {
			printf("Customer %d arrived.\n", i);
		}
		Sleep(100);
	}

	int j;
	for (j = 0; j < CUSTOMER_NUM; j++)
	{
		pthread_join(customers[j], nullptr);
	}

	sem_destroy(&sem);
	return 0;
}

其中,if(sem_wait(&sem)==0)表示当前信号量大于0,可以为该顾客服务,并将信号量-1,服务完成后,就得调用sem_post把信号量+1,以便继续为其它顾客服务:

void * get_service(void * thread_id)
{
	int customer_id=*((int *)thread_id);
	if (sem_wait(&sem) == 0) //sem_wait函数调用成功时返回0,失败返回-1.
	{
		Sleep(100);//服务时间:100ms
		printf("customer %d receive service ...\n", customer_id);
		sem_post(&sem);
	}
}

  1. 并行与并发:并发是交替执行,而并行是同时执行。 ↩︎

  2. 同时只允许一个进程访问的资源称为临界资源,每个线程中访问临界资源的那段代码称为临界区。每次只允许一个线程进入临界区,当有线程进入临界区时,其他线程必须等待。 ↩︎

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值