C++ 中的竞争条件
在本文中,我们将了解竞争条件的概念。 首先,我们将介绍线程,然后我们将通过示例讨论线程中的竞争条件。
最后,我们将看到避免/控制竞争条件的解决方案。
什么是线程
线程是轻量级进程。 进程可以有多个线程,它们共享进程的许多资源。
线程是为了协作; 因此,它们在其他资源之间共享内存/变量。
线程用于在进程内运行并发任务。 例如,MS Word 处理过程同时执行多个任务。
这些任务包括拼写、语法、自动保存、标记关键字、缩进和格式设置。 我们可以使用单独的线程来同时运行这些并发任务。
并行排序和打印
在这里,我们将讨论一个编码示例,以更好地理解线程。 假设我们必须对许多值进行排序和打印。
首先,我们必须把它们彻底整理好,然后才能打印。
然而,有一些排序技术,例如选择排序,它对数字进行排序,以便在每次迭代中,我们将在列表的开头获得较小的元素。 因此,我们可以开始并行打印。
这是代码:
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#define SIZE 1000
int x[SIZE];
void init(){
int i;
for (i=0;i<SIZE;i++)
x[i] = rand() % 1000;
}
void* print(void* a){
int i;
for (i=0;i<SIZE;i++)
printf("%d ", x[i]);
printf("\n");
}
void* sort(void* b){
int i, j, temp;
for (i=0;i<SIZE-1;i++)
for (j=SIZE-1;j>=1;j--)
if (x[j]<x[j-1]){
temp = x[j];
x[j] = x[j-1];
x[j-1] = temp;
}
}
int main(){
srand(time(0));
init();
pthread_t tSort,tPrint;
pthread_create(&tSort, NULL, sort, NULL);
pthread_create(&tPrint, NULL, print, NULL);
pthread_join(tSort, NULL);
pthread_join(tPrint, NULL);
return 0;
}
在这段代码中,排序和打印函数是并行运行的; 但是,如果您看到输出,则它不会正确。
原因是排序线程比打印线程慢,并且它们之间没有同步。 因此,打印线程会快速打印尚未排序的值; 因此,输出是未排序的。
多线程程序很容易出现这些同步问题和其他一些问题。 接下来,我们将讨论一个称为竞争条件的问题。
什么是竞争条件 竞争条件是当多个进程或线程同时访问某些共享数据时可能发生的不良情况。
最终值取决于最后访问数据的进程/线程。 让我们用一个例子来解释一下。
假设我们有两个线程 A 和 B,以及一个共享变量 x。 两个线程都并行运行指令 x++。
如果您用低级语言仔细检查该指令,就会发现该指令由三个指令组成。 这三个指令是:
read x
inc x
write x
第一条指令将变量从内存读取到寄存器(处理器内部)。 在第二条指令中,变量加1,结果存放在寄存器中。
该变量在第三条也是最后一条指令中写回内存。
假设两个线程并行运行这些指令。 我们不知道它们的执行顺序; 它取决于多种因素,如调度算法、时间片、进程的优先级以及正在运行/新进程的集合等。
我们可能有多个可能的序列来执行这些指令,并且每次都可能发生不同的序列。 例如,线程A可能会执行前两条指令,操作系统会切换上下文。
假设变量的值为 5,线程 A 读取了 5 并将其加 1,所以现在该值为 6(但在寄存器内部)。 当上下文切换并且线程 B 开始执行时,它也会读取相同的值 5。
线程 B 会将值加 1。该值变为 6。
线程 B 完成最后一条指令并将 6 值写入内存。
上下文再次切换,控制权最终到达线程A; 现在,线程A将执行最后一条指令,写入x。
回想一下,线程 A 读取了值 5 并将其加 1。线程 A 还将 6 写入内存。
结果,该值将是6,而不是7,因为两个线程执行了增量操作,该值应该是7。因此,由于意外的上下文切换,结果不正确。
生产者-消费者问题
在讨论生产者-消费者问题之前,让我们先看一下以下编码示例。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
int count = 0;
void* producer(void * arg){
for(int i=0; i<50000; i++)
count++;
}
void* consumer(void * arg){
for(int i=0; i<50000; i++)
count--;
}
int main(){
pthread_t tid1, tid2;
printf("Count = %d\n", count);
pthread_create(&tid1, NULL, producer, NULL);
pthread_create(&tid2, NULL, consumer, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
printf("Count = %d\n", count);
return 0;
}
此代码演示了称为生产者-消费者问题的经典操作系统问题。 此代码有一个全局(共享)变量 count,初始化为 0。
生产者函数将计数加 1,消费者函数将计数减 1。
然而,这两个函数都由两个不同的线程运行,上下文切换可以随时发生。 您可以通过运行代码轻松体验这一点。
如果不存在竞争条件,则结果应为 0,因为两个函数都执行相同次数的递增和递减操作。 单位增量和减量意味着计数值最终应为零。
但是,当您运行此代码时,您会发现该值不为零。 相反,每次迭代都会出现不同的值,这清楚地表明调度是不可预测的。
一些实际的生产者-消费者示例
上面讨论的两个线程的增量示例不是一个假设问题; 使用多线程时存在许多这样的实际场景。
例如,在银行系统中,客户有两种提取金额的选择。 一种是向银行出示支票,同时,有人可以使用客户的 ATM 卡从 ATM 提取金额。
同样,两个或多个教师可能会偶然更新/发布同一学生的分数,从而增加/减少学生的总分数。 两家或更多公司可以同时扣除客户的每周、每月或每年的订阅费。
竞争条件可能会导致不正确的结果,这是无法承受的。 因此,我们是否应该宣布,由于竞争条件问题,多线程的适用性在很多场景下并不实用?
不,我们有避免/控制竞争条件的解决方案。
避免竞争条件
我们拥有针对竞争条件的硬件和软件解决方案。 硬件解决方案是提供相关的原子指令作为CPU指令集的一部分(例如x86架构中的CMPXCHG)。
该软件解决方案使用互斥体或信号量来规范进程进入关键部分的情况。
原子操作
制造商可以在硬件中实现原子操作。 在硬件中实现原子操作后,此类指令无需上下文切换即可完成执行。 换句话说,进程/线程的执行不应该被分成几部分。
然而,该方案是针对制造商的,普通用户无法实施。
相互排斥
互斥意味着如果一个进程/线程正在访问某个共享变量,则不应允许其他进程/线程访问另一个进程/线程已持有的共享变量。
互斥可以使用互斥锁(锁)或信号量来实现。 您可以点击阅读有关 Mutex 与 Semaphore 的文章来阐明概念。
使用互斥体避免竞争条件
互斥量是一种特殊锁的概念。 该锁在线程之间共享; 但是,如果多个线程想要锁定互斥锁,则只有一个(第一个)线程会成功锁定并进入临界区。
尝试锁定互斥体的剩余线程将被阻塞,直到第一个线程走出临界区并解锁互斥体。
一个未锁定的互斥锁可以随时被一个线程锁定。 在解锁互斥体时,任何阻塞线程都可以获得锁定互斥体并进入临界区的机会。
mutex中没有队列的概念。 任何阻塞/等待线程都有机会获取互斥体并锁定它。
这是一个非常简单的解决方案,当我们有两个线程处于竞争条件或多个线程处于竞争条件并且不存在轮流/队列问题时非常有用。
让我们使用以下 C 示例来实现该解决方案,该示例使用 pthread Linux 库实现互斥锁。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
int count = 0;
pthread_mutex_t lock;
void* producer(void * arg){
for(int i=0; i<50000; i++){
pthread_mutex_lock(&lock);
count++;
pthread_mutex_unlock(&lock);
}
}
void* consumer(void * arg){
for(int i=0; i<50000; i++){
pthread_mutex_lock(&lock);
count--;
pthread_mutex_unlock(&lock);
}
}
int main(){
pthread_t tid1, tid2;
printf("Count = %d\n", count);
pthread_mutex_init(&lock, NULL);
pthread_create(&tid1, NULL, producer, NULL);
pthread_create(&tid2, NULL, consumer, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_mutex_destroy(&lock);
printf("Count = %d\n", count);
return 0;
}
您可以多次运行此代码。 每次,它都会给出 count 0 的值。
该代码有一个特殊类型的共享变量锁,pthread_mutex_t。 生产者和消费者函数在执行临界区指令 count++ 和 count-- 之前被锁定。
同样,两者都在临界区结束后调用解锁函数来释放互斥体。
在main函数中,我们初始化互斥体,并在使用互斥体后销毁互斥体。
使用信号量避免竞争条件 信号量是操作系统提供的一种解决方案。 信号量是线程同步的一种解决方案。
我们可能需要一些特定的指令执行顺序; 然而,我们可以用它来避免竞争条件,但这不是一个精确的解决方案; 稍后我们将讨论原因。
在这里,我们避免了冗长而详细的同步主题,而是使用信号量来避免竞争条件。 使用信号量是在某个特定点停止线程并等待来自另一个线程的信号。
我们将信号量初始化为零并调用等待函数,将信号量减 1 并进入睡眠状态。
随后,另一个线程将调用信号操作,将信号量加 1,并唤醒队列中某个正在休眠的线程。
在 Linux 中,信号量可在库 semaphore.h 中找到。 我们有用于等待和信号操作的等待和发布函数。
看代码:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
int count = 0;
sem_t semaphore1, semaphore2;
void* producer(void * arg){
for(int i=0; i<50000; i++){
count++;
sem_post(&semaphore1);
sem_wait(&semaphore2);
}
}
void* consumer(void * arg){
for(int i=0; i<50000; i++){
sem_wait(&semaphore1);
count--;
sem_post(&semaphore2);
}
}
int main(){
pthread_t tid1, tid2;
printf("Count = %d\n", count);
sem_init(&semaphore1, 0, 0);
sem_init(&semaphore1, 0, 0);
pthread_create(&tid1, NULL, producer, NULL);
pthread_create(&tid2, NULL, consumer, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
sem_destroy(&semaphore1);
sem_destroy(&semaphore2);
printf("Count = %d\n", count);
return 0;
}
同样,您可以运行此代码并获取共享变量的准确/正确值。 实现类似于互斥锁; 然而,一个主要区别是初始化步骤。
初始化函数中共有三个参数。 第一个是信号量变量。
第二个是 0 或 1。这里,0 表示信号量在同一进程的线程之间共享,而 1 表示信号量在不同进程的线程之间共享。
第三个参数是信号量的值; 如果你希望多个线程(但限于某个计数)可以一起进入临界区,你可以用计数来初始化信号量值。
此实现中的第二个区别是我们使用两个信号量,而在互斥锁的情况下我们只使用一个锁。
互斥锁代码在使用共享变量之前加锁,并在使用共享变量之后释放锁。 每当顾客要操作银行更衣室的储物柜时,工作人员都会给顾客一把钥匙,在打开储物柜前锁上储物柜,锁上锁后再打开更衣室。
然而,在信号量中,我们使用两个信号量; 两者都初始化为 0,这意味着调用线程等待将被阻塞,直到其他线程发出信号。 这里,消费者在增加计数后正在等待由生产者线程发出的信号量 1。
同时,生产者线程正在等待信号量 2,该信号量将在计数递减后由消费者线程发出信号。
这样生产者和消费者就会一一递增和递减计数,这就是同步。 使用信号量,我们可以保持计数正确; 但是,我们限制线程按顺序执行。
在互斥体的情况下,两个线程可以按任何顺序执行,而不会损害共享变量计数的值。
总结
线程对于并行运行代码/函数非常有用; 但是,线程存在一些问题,例如竞争条件。 这种情况可能会导致意想不到的结果。
幸运的是,我们可以使用互斥锁和信号量来避免竞争条件。 处理竞争条件的最佳且正确的方法是互斥体。
相关文章
在 C++ 中使用 TextOut() 更新文本
发布时间:2023/08/31 浏览次数:109 分类:C++
-
C++ 中的 TextOut() 函数使用选定的字体、背景颜色和文本颜色在指定位置写入字符串。 它属于`#include wingdi.h`。在本文中,您将学习如何使用 C++ 中的 TextOut() 函数更新任何文本。
用 C++ 测试射线三角形相交
发布时间:2023/08/28 浏览次数:76 分类:C++
-
测试光线-三角形相交可能需要数百万次测试,并且被认为是 C++ 中任何光线追踪器的核心操作之一(需要为每种几何基元类型提供不同的函数实现)。 本教程将教您如何用 C++ 编写射线-三角形
在 C++ 中取消引用迭代器
发布时间:2023/08/28 浏览次数:64 分类:C++
-
迭代器是 C++ 中的另一种强大机制,可帮助您迭代复杂的数据结构(例如树)和内部没有元素索引的集合(例如数组)。C++ 中的迭代器是什么 在 C++ 中,迭代器是一个类似于指针的对象,指向数
在 C++ 中实现双向链表的迭代器
发布时间:2023/08/28 浏览次数:193 分类:C++
-
由于双向链接数据结构由一组顺序链接的节点组成,其起始节点和结束节点的上一个和下一个链接都指向一个终止符(通常是哨兵节点或空),以方便链表的遍历。 本教程将教您如何在 C++ 中实
用 C++ 编写系统调用
发布时间:2023/08/28 浏览次数:161 分类:C++
-
本文将讨论从 C++ 编写的程序中调用写入系统的方法。 首先,我们将快速刷新系统调用,特别是 write 系统调用及其原型。稍后,我们将讨论从 C++ 程序调用 write 系统调用。
在 C++ 中获取鼠标位置
发布时间:2023/08/28 浏览次数:137 分类:C++
-
本文讲述如何在 C++ 中获取鼠标位置。在 C++ 中获取鼠标位置 C++ 提供了 GetCursorPos 方法来获取鼠标光标的 x 和 y 位置。
C++ 中的多维向量
发布时间:2023/08/28 浏览次数:152 分类:C++
-
这个简短的编程教程是关于 C++ 中多维向量的介绍。 向量是可以像 C++ 中的动态数组一样存储数据的容器,具有自动调整大小的功能。C++ 中的多维向量
在 C++ 中释放 std::vector 对象
发布时间:2023/08/28 浏览次数:52 分类:C++
-
本文将解释如何在 C++/C 中释放 std::vector 对象。 首先,我们将了解释放的自编码方式,然后,我们将研究如何在 C++ 中动态释放 std::vector 对象。在 C++ 中释放对象
在 C++ 中计算两个向量之间的角度
发布时间:2023/08/28 浏览次数:175 分类:C++
-
矢量数学是处理矢量的数学分支,矢量是具有大小和方向的几何对象。 例如,向量尾部形成的角度等于两个向量形成的角度