文章目录
一、volatile关键字与互斥锁介绍
(1)volatile关键字
在C语言中,使用volatile关键字可以告诉编译器某个变量是易变的,需要每次从内存中读取或写入,而不是对变量进行优化缓存。因为编译器会对变量进行各种优化,比如寄存器优化、指令重排等等,这些优化可能会导致变量的读写顺序出现问题,从而导致程序错误。
在多线程编程中,如果一个变量被多个线程同时访问和修改,那么就会出现竞态条件问题,为了避免这种问题的发生,需要使用同步机制来保护共享变量。但是,同步机制仅能确保互斥访问,不能确保变量访问操作的顺序性和完整性。而使用volatile可以告诉编译器,这个变量可能会被其他线程修改,不要优化掉它的读写指令,必须从内存中读取它的值,并把它写回内存。
(2)互斥锁
互斥锁是一种用于同步多线程、避免竞态条件(Race Condition)问题的机制。在多线程环境下,当多个线程同时访问共享资源时,可能会出现读写冲突的情况,从而导致数据不一致或程序崩溃。
为了避免这种情况发生,我们需要使用互斥锁来对共享资源进行保护。当一个线程获取到互斥锁后,其他线程就不能再获取该锁,只能等待当前线程释放锁后才能重新竞争获取锁。
互斥锁的基本操作包括加锁和解锁。当一个线程想要访问共享资源时,它必须先尝试获取互斥锁,如果锁已经被其他线程持有,则当前线程会一直阻塞,直到获取到锁为止。当线程访问完共享资源后,需要释放锁,以便其他线程可以继续访问资源。
在实际编程中,互斥锁一般是由操作系统提供的,我们可以通过系统调用来使用互斥锁。通常还会有一些高级的同步机制,如读写锁、信号量等,它们都是基于互斥锁实现的。
需要注意的是,使用互斥锁不是万能的解决方案,它可能会带来一些额外的开销和问题,比如死锁、优先级反转等。因此,在使用互斥锁时,需要根据实际情况进行权衡,并考虑其他同步机制的可能性。
二、volatile关键字与互斥锁的作用
三个例子告诉你他们的作用。
(1)第一个代码实例
#include <stdio.h>
#include <pthread.h>
int counter = 0; // 声明为volatile类型的共享变量
void *thread_func(void *arg)
{
int i;
for (i = 0; i < 100000; i++)
{
counter++; // 对共享变量进行加1操作
}
pthread_exit(NULL); // 终止线程
}
int main(int argc, char * argv[])
{
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, thread_func, NULL); // 创建线程1
pthread_create(&tid2, NULL, thread_func, NULL); // 创建线程2
pthread_join(tid1, NULL); // 等待线程1结束
pthread_join(tid2, NULL); // 等待线程2结束
printf("counter = %d\n", counter); // 输出最终计数器的值
return 0;
}
结果:
在上述代码中,我们没有使用互斥锁或其他同步机制来保护共享变量counter,因此会出现竞态条件的问题。两个线程将同时对计数器进行加1操作,由于两个线程的执行顺序和时间都不确定,它们有可能会在相同的时刻读取并修改同一个变量,从而导致不可预测的结果发生。
例如,假设线程1和线程2同时读取了计数器的值为100,然后各自加1并将结果写回,那么最终的计数器值应该是102,但是由于两个线程的运行顺序不确定,可能先执行线程1,也可能先执行线程2,因此最终计数器的值可能是101或者更小的值,而不是102。
(2)第二个代码实例
我们在第一个代码的例子上添加上互斥锁:
#include <stdio.h>
#include <pthread.h>
int counter = 0; // 声明为volatile类型的共享变量
pthread_mutex_t mutex; // 声明互斥锁
void *thread_func(void *arg) {
int i;
for (i = 0; i < 100000; i++) {
pthread_mutex_lock(&mutex); // 获取互斥锁
counter++; // 对共享变量进行加1操作
pthread_mutex_unlock(&mutex); // 释放互斥锁
}
pthread_exit(NULL); // 终止线程
}
int main(int argc, char * argv[]) {
pthread_t tid1, tid2;
pthread_mutex_init(&mutex, NULL); // 初始化互斥锁
pthread_create(&tid1, NULL, thread_func, NULL); // 创建线程1
pthread_create(&tid2, NULL, thread_func, NULL); // 创建线程2
pthread_join(tid1, NULL); // 等待线程1结束
pthread_join(tid2, NULL); // 等待线程2结束
printf("counter = %d\n", counter); // 输出最终计数器的值
return 0;
}
结果:
在上述代码中,我们使用了互斥锁来保护共享变量counter,从而避免了竞态条件的问题。每个线程在修改计数器之前都会先获取互斥锁,因此只有一个线程能够进入临界区,保证了计数器的互斥访问。
具体来说,当一个线程调用pthread_mutex_lock()函数获取互斥锁时,如果其他线程正在使用这个锁,那么该线程将被阻塞,直到该锁被释放为止。这样就可以确保每次只有一个线程能够访问临界区,避免了对共享变量的同时访问。
另外,由于多个线程共享同一个内存空间,因此修改共享变量时需要考虑内存缓存一致性的问题。在上述代码中,使用了互斥锁来同步线程,以确保每个线程都能及时地读取到其他线程对共享变量所做的修改,从而避免了共享变量的数据不一致问题。
所以我们的结果是正确的。
(3)第三个代码实例
我们在上述代码中添加上volatile关键字:
#include <stdio.h>
#include <pthread.h>
volatile int counter = 0; // 声明为volatile类型的共享变量
pthread_mutex_t mutex; // 声明互斥锁
void *thread_func(void *arg) {
int i;
for (i = 0; i < 100000; i++) {
pthread_mutex_lock(&mutex); // 获取互斥锁
counter++; // 对共享变量进行加1操作
pthread_mutex_unlock(&mutex); // 释放互斥锁
}
pthread_exit(NULL); // 终止线程
}
int main() {
pthread_t tid1, tid2;
pthread_mutex_init(&mutex, NULL); // 初始化互斥锁
pthread_create(&tid1, NULL, thread_func, NULL); // 创建线程1
pthread_create(&tid2, NULL, thread_func, NULL); // 创建线程2
pthread_join(tid1, NULL); // 等待线程1结束
pthread_join(tid2, NULL); // 等待线程2结束
printf("counter = %d\n", counter); // 输出最终计数器的值
return 0;
}
结果:
运行的结果依旧是正确的,我们为什么要添加volatile关键字呢?有的人可能会想:加互斥锁就可以了为什么还需要声明volatile类型的共享变量才行?
在多线程环境下,如果一个变量被多个线程访问和修改,那么在没有同步机制的情况下会出现竞态条件问题。为了避免竞态条件问题,我们需要使用同步机制来保护共享变量。
互斥锁是一种常用的同步机制,可以确保同时只有一个线程可以访问和修改共享变量,从而避免了竞态条件问题。因此,在上述示例代码中添加了互斥锁之后,可以保证多个线程对计数器变量的访问和修改是安全和正确的。
但是,即使使用了互斥锁,依然需要将共享变量声明为volatile类型的变量。这是因为,在多线程程序中,除了访问和修改共享变量之外,还存在其他的操作,例如对变量地址的读取和写入操作。如果没有将共享变量声明为volatile类型的变量,则==编译器可能会对程序进行优化,将变量缓存到寄存器或高速缓存中,而不是每次从内存中读取变量。这样就可能会出现一个线程读取到另一个线程修改后的过期数据,从而导致程序错误。==而将变量声明为volatile类型的变量,可以告诉编译器不要对该变量进行优化,必须在每次读取和写入变量时都从内存中读取和写入。
因此,为了确保多线程程序的正确性,我们需要同时使用互斥锁和将共享变量声明为volatile类型的变量。这样才能确保所有操作都是同步的,并且不会出现数据过期的问题。
三、扩展(原子操作)
使用原子操作来解决多线程的数据竞争问题:
#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h>
atomic_int counter = ATOMIC_VAR_INIT(0); // 原子变量
void *thread_func(void *arg) {
int i;
for (i = 0; i < 100000; i++) {
atomic_fetch_add(&counter, 1); // 对原子变量进行加1操作
}
pthread_exit(NULL); // 终止线程
}
int main(int argc, char * argv[]) {
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, thread_func, NULL); // 创建线程1
pthread_create(&tid2, NULL, thread_func, NULL); // 创建线程2
pthread_join(tid1, NULL); // 等待线程1结束
pthread_join(tid2, NULL); // 等待线程2结束
printf("counter = %d\n", counter); // 输出最终计数器的值
return 0;
}
结果:
在使用原子变量时,不需要使用volatile关键字来修饰变量。因为原子变量本身已经在语言标准中定义了内存顺序,对原子变量的操作会自动同步到内存中,确保多线程程序的正确性。
事实上,volatile和原子变量是两种不同的机制,其作用也不同。volatile关键字只是告诉编译器不要对变量进行优化,而原子变量则可以保证多线程访问的同步性。
使用volatile关键字修饰共享变量时,仍然需要使用其他机制来保证多线程程序的正确性,比如使用互斥锁。虽然volatile关键字可以防止编译器对变量进行优化,但是它并不能保证多线程访问的同步性,因此并不能彻底解决多线程程序中的数据竞争问题。