30. 并发编程
一、什么是多任务
如果一个操作系统上同时运行了多个程序,那么称这个操作系统就是 多任务的操作系统,例如:Windows、Mac、Android、IOS、Harmony 等。如果是一个程序,它可以同时执行多个事情,那么就称为 多任务的程序。
一个 CPU 默认可以执行一个程序,如果想要多个程序一起执行,理论上就需要多个 CPU 来执行。
如果一个 CPU 是一个核心,理论上只能同时运行一个任务,但是事实上却可以运行很多个任务。这是因为操作系统控制着 CPU,让 CPU 做了一个特殊的事情,一会运行一个任务,然后快速的运行另一个任务,依次类推,实现了多个任务,看上去“同时”运行多个任务。
并发:是一个对假的多任务的描述;
并行:是真的多任务的描述;
二、进程与线程
计算机程序只是存储在磁盘上的可执行二进制(或其它类型)文件。只有把它们加载到内存中从被操作系统调用,才拥有其生命期。
进程(process)则是一个执行中的程序。每个进程都拥有自己的地址空间、内存、数据栈以及其它用于跟踪执行的辅助数据。操作系统管理其上所有进程的执行,并为这些进程合理分配时间。进程也可以通过派生新的进程来执行其它任务,不过因为每个新进程也都拥有自己的内存和数据栈等,所以只能采用进程间通信的方式共享数据;
线程(thread)与进程类似,不过它们是同一个进程下执行的,并共享相同的下上文。线程包括开始、执行顺序和结束三部分。它有一个指令指针,用于记录当前运行的上下文。当其它线程运行时,它可以被抢占(中断)和临时挂起(也称为睡眠)—— 这种做法叫做让步(yielding)。
一个进程中的各个线程与主线程共享同一片数据空间。线程一般是以并发方式执行的。在单核 CPU 系统中,因为真正的并发是不可能的,所以线程的执行实际上是这样规划的:每个线程运行一小会,然后让步给其它线程(再次排队等待更多的 CPU 时间)。在整个进程的执行过程中,每个线程执行它自己特定的任务,在必要时和其它线程进行结果通信。
但是这种共享数据也是存在风险的。如果两个或多个线程访问同一片数据,由于数据访问顺序不同,可能导致结果不一致。这种情况通常称为 “竞态条件”(race condition)。另一个需要注意的问题时,线程无法给予公平的执行时间。这是因为一些函数会在完成前保持阻塞状态,如果没有专门为多线程情况进行修改,会导致 CPU 的时间分配向这些贪婪的函数倾斜。
线程是计算机中可以被 CPU 调度的最小单元,进程是计算机资源分配的最小单元;进程作为资源分配的单位,系统在运行时会为每个进程分配不同的内存区域;
一个程序,至少有一个进程,一个进程中至少有一个线程,最终是线程在工作;
一个进程内可以开设多个线程,在用一个进程内开设多个线程无需再次申请空间及拷贝代码的操作,开设线程的开销远远的要小于进程的开销;
单核 CPU,其实是一种假的多线程,因为在一个时间单元内,也只能执行一个线程的任务。但是因为 CPU 时间单元特别短,因此感觉不出来;
三、线程的生命周期
要想实现多线程,必须在主线程中创建新的线程对象。Python 中使用 threading 模块或者 Thread 子类来表示线程,在它的一个完整的生命周期中通常要经过如下的五种状态:
- 创建:当一个 Thread 类或及其子类的对象被声明并创建时,新生的线程就处于新建状态;
- 就绪:处于新建的线程被 start() 后,将进入线程队列等待 CPU 时间片,此时它已具备了运行的条件,只是没分配到 CPU 资源;
- 运行:当就绪的线程被调度并获得 CPU 资源时,便进入运行状态,run() 方法定义了线程的操作和功能;
- 阻塞:在某种特殊情况下,被人为挂起或执行输入输出操作时,让出 CPU 并临时中止自己的执行,进入阻塞状态;
- 退出:线程完成了它的全部或线程被提前强制性中止或出现异常导致结束;
四、线程的基本操作
C++ 11 标准提供了 thread 类模板用于创建线程,该类模板定义在 thread 标准库中,因此在创建线程时,需要包含 thread 头文件。thread 类模板定义了一个无参构造函数和一个变参构造函数,因此在创建线程对象时,可以为线程传入参数,也可以不传入参数。需要注意的是,thread 类模板不提供拷贝构造函数、赋值运算符重载等函数,因此线程对象之间不可以进行拷贝、赋值等操作。
除了构造函数,thread 类模板还定义了两个常用的成员函数:join()
函数和 detach()
函数。
join()
函数:该函数将线程和线程对象连接起来,即将子线程加入程序执行。join() 函数是阻塞的,它可以阻塞主线程(当前线程),等待子线程工作结束之后,再启动主线程继续执行任务。detach()
函数:该函数分离线程与线程对象,即主线程和子线程可同时进行工作,主线程不必等待子线程结束。但是,detach() 函数分离的线程对象不能再调用 join() 函数将它与线程连接起来。
#include <iostream>
#include <thread>
using namespace std;
void func(string name)
{
cout << name << "程开始工作" << endl;
cout << name << "结束工作" << endl;
}
int main(void)
{
cout << "主线程开始工作" << endl;
// 创建线程并执行函数
thread t(func, "线程1");
// 判断一个线程是否可以使用join()
// 如果一个线程不可以使用join()但强行使用join()编译器会报错
if (t.joinable())
{
// 等待线程结束
t.join();
}
cout << "主线程结束工作" << endl;
return 0;
}
第 7~11 行代码定义了函数 func()。第 18 行代码创建线程对象 t,传入 func() 函数名作为参数,即创建一个子线程去执行 func() 函数的功能。第 25 行代码调用 join() 函数阻塞主线程。主线程等待子线程工作结束之后才结束工作。
在 C++ 多线程中,线程对象与线程是相互关联的,线程对象出了作用域之后就会被析构,如果此时线程函数还未执行完,程序就会发生错误,因此需要保证线程函数的生命周期在线程对象生命周期之内。一般通过调用 thread 中定义的 join()
函数阻塞主线程,等待子线程结束,或者调用 thread 中的 detach()
函数将线程与线程对象进行分离,让线程在后台执行,这样即使线程对象生命周期结束,线程也不会受到影响。
在上述代码中,将 join() 函数替换为 detach() 函数,将线程对象与线程分离,让线程在后台运行,再次运行程序,运行结果就可能发生变化。即使 main() 函数(主线程)结束,子线程对象 t 生命周期结束,子线程依然会在后台将 func() 函数执行完毕。
C++ 11 标准定义了 this_thread 命名空间,该空间提供了一组获取当前线程信息的函数,分别如下所示:
get_id()
函数:获取当前线程id。yeild()
函数:放弃当前线程的执行权。操作系统会调度其他线程执行未用完的时间片,当时间片用完之后,当前线程再与其他线程一起竞争 CPU 资源。sleep_until()
函数:让当前线程休眠到某个时间点。sleep_for()
函数:让当前线程休眠一段时间。
#include <iostream>
#include <thread>
using namespace std;
void func(string &name)
{
cout << name << "开始工作" << endl;
cout << name << "结束工作" << endl;
}
int main(void)
{
cout << "主线程开始工作" << endl;
// 创建线程并执行函数
string name = "线程A";
thread t(func, ref(name));
t.join();
cout << "主线程结束工作" << endl;
return 0;
}
上述程序中,如果我们把 t.join()
替换成 t.detach()
,程序可以运行错误。这是因为 t.detach() 函数分离线程与线程对象,即主线程和子线程可同时进行工作,主线程不必等待子线程结束。如果这是主线程先执行完毕,引用或指针指向的 name 变量的内存会释放。
如果我们传递的参数是引用或指针类型的变量时,需要注意引用或指针类型指向的变量的内存地址是否已经被释放了。
五、线程同步问题
如果一个程序有多个线程,每个线程可以单独执行自己的任务。如果多个线程之间需要数据共享,我们可以通过全局变量的方式实现。一个线程修改了全局变量,其它线程可以读取这个修改后的全局变量。
多个线程操作同一份数据时,可能会出现数据错乱的问题。例如,有 3 个线程,其中线程 1 和线程 2 修改全局变量,线程 3 获取全局变量的值。可能会出现第 线程 1 刚刚将数据存放到了全局变量中,本意是想让线程 3 获取它的数据,但是因为操作系统的调度原因导致线程 3 没有被调度,而线程 2 被调度了,恰巧线程 2 也对全局变量进行了修改。而当线程 3 去读取数据时,读取到的是线程 2 修改的数据,而不是线程修改的数据。
多个线程操作同一份数据时,可能会出现数据错乱的问题。针对上述问题,解决方式就是加锁处理:将并发变成串行,牺牲效率但保证了数据的安全。
某个线程要更改共享数据时,先将其锁定,此时资源的状态为 “锁定” ,其它线程不能更改;直到该线程释放资源,将资源的状态变成 “非锁定”,其它的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。
C++ 11 标准提供了互斥锁 mutex,用于为共享资源加锁,让多个线程互斥访问共享资源。mutex 是一个类模板,定义在 mutex 标准库中,使用时要包含 mutex 头文件。mutex 类模板定义了三个常用的成员函数:lock()
函数、unlock()
函数和 try_lock()
函数,用于实现上锁、解锁功能。
lock()
函数:用于给共享资源上锁。如果共享资源已经被其他线程上锁,则当前线程被阻塞;如果共享资源已经被当前线程上锁,则产生死锁。unlock()
函数:用于给共享资源解锁,释放当前线程对共享资源的所有权。try_lock()
函数:也用于给共享资源上锁,但它是尝试上锁,如果共享资源已经被其他线程上锁,try_lock() 函数返回 false,当前线程并不会被阻塞,而是继续执行其他任务;如果共享资源已经被当前线程上锁,则产生死锁。
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
int ticket = 100;
mutex mtx; // 互斥锁对象
void task(string name);
void sell(string name);
int main(void)
{
thread t1(task, "窗口1");
thread t2(task, "窗口2");
thread t3(task, "窗口3");
t1.join();
t2.join();
t3.join();
return 0;
}
void task(string name)
{
while (ticket > 0)
{
mtx.lock(); // 加锁
sell(name);
mtx.unlock(); // 解锁
}
}
void sell(string name)
{
if (ticket > 0)
{
cout << name << "卖票,票号为:" << ticket << endl;
ticket--;
this_thread::sleep_for(chrono::milliseconds(100));
}
}
第 8 行代码定义了互斥锁 mtx。第 26~34 行代码定义了函数 task(),在 task() 函数内部,通过对象 mtx 调用 lock() 函数,为后面的代码上锁;第 32 行代码通过对象 mtx 调用 unlock() 函数解锁。当某个线程获取互斥锁 mtx 时,该线程会为第 36~ 44 行代码上锁,即拥有了 buy() 函数的所有权,在解锁之前,其他线程不能执行 buy() 函数。
不知道为什么大部分都是只有一个窗口卖票,但是多运行几次或把 ticket 改大一些会发现其它窗口也卖票;
六、lock_guard和unique_lock
通过 mutex 的成员函数为共享资源上锁、解锁,能够保证共享资源的安全性。但是,通过 mutex 上锁之后必须要手动解锁,如果忘记解锁,当前线程会一直拥有共享资源的所有权,其他线程不得访问共享资源,造成程序错误。此外,如果程序抛出了异常,mutex 对象无法正确地析构,导致已经被上锁的共享资源无法解锁。
为此,C++ 11 标准提供了 RAII 技术的类模板:lock_guard
和 unique_lock
。lock_guard 和 unique_lock 可以管理 mutex 对象,自动为共享资源上锁、解锁,不需要程序设计者手动调用 mutex 的 lock() 函数和 unlock() 函数。即使程序抛出异常,lock_guard 和 unique_lock 也能保证 mutex 对象正确解锁,在简化代码的同时,也保证了程序在异常情况下的安全性。
6.1、lock_guard
lock_guard 可以管理一个 mutex 对象,在创建 lock_guard 对象时,传入 mutex 对象作为参数。在 lock_guard 对象生命周期内,它所管理的 mutex 对象一直处于上锁状态;lock_guard 对象生命周期结束之后,它所管理的 mutex 对象也会被解锁。
- 当构造函数被调用时,该互斥量会自动被锁定。
- 当析构函数被调用时,该互斥量会自动解锁
- std::lock_guard 对象不能复制或移动,因此它只能在局部作用域中使用。
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
int ticket = 1000;
mutex mtx; // 互斥锁对象
void task(string name);
void buy(string name);
int main(void)
{
thread t1(task, "窗口1");
thread t2(task, "窗口2");
thread t3(task, "窗口3");
t1.join();
t2.join();
t3.join();
return 0;
}
void task(string name)
{
while (ticket > 0)
{
lock_guard<mutex> locker(mtx);
buy(name);
}
}
void buy(string name)
{
if (ticket > 0)
{
cout << name << "卖票,票号为:" << ticket << endl;
ticket--;
this_thread::sleep_for(chrono::milliseconds(100));
}
}
第 30 行代码创建了 lock_guard 对象 locker,传入互斥锁 mtx 作为参数,即对象 locker 管理互斥锁 mtx。当线程执行 buy() 函数时,locker 会自动完成对 buy() 函数的上锁、解锁功能。
需要注意的是,lock_guard 对象只是简化了 mutex 对象的上锁、解锁过程,但它并不负责 mutex 对象的生命周期。在上述例子中,当 buy() 函数执行结束时,lock_guard 对象 locker 析构,mutex 对象 mtx 自动解锁,线程释放 buy() 函数的所有权,但对象 mtx 的生命周期并没有结束。
6.2、unique_lock
lock_guard 只定义了构造函数和析构函数,没有定义其他成员函数,因此它的灵活性太低。为了提高锁的灵活性,C++ 11 标准提供了另外一个 RAII 技术的类模板 unique_lock。unique_lock 与 lock_guard 相似,都可以很方便地为共享资源上锁、解锁,但 unique_lock 提供了更多的成员函数,它有多个重载的构造函数,而且 unique_lock 对象支持移动构造和移动赋值。需要注意的是,unique_lock 对象不支持拷贝和赋值。
lock()
函数:为共享资源上锁,如果共享资源已经被其他线程上锁,则当前线程被阻塞;如果共享资源已经被当前线程上锁,则产生死锁。try_lock()
函数:尝试上锁,如果共享资源已经被其他线程上锁,该函数返回 false,当前线程继续其他任务;如果共享资源已经被当前线程上锁,则产生死锁。try_lock_for()
函数:尝试在某个时间段内获取互斥锁,为共享资源上锁,如果在时间结束之前一直未获取互斥锁,则线程会一直处于阻塞状态。try_lock_until()
函数:尝试在某个时间点之前获取互斥锁,为共享资源上锁,如果到达时间点之前一直未获取互斥锁,则线程会一直处于阻塞状态。unlock()
函数:解锁。
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
int ticket = 100;
timed_mutex mtx; // 时间互斥锁对象
void task(string name);
void buy(string name);
int main(void)
{
thread t1(task, "窗口1");
thread t2(task, "窗口2");
thread t3(task, "窗口3");
t1.join();
t2.join();
t3.join();
return 0;
}
void task(string name)
{
while (ticket > 0)
{
// defer_lock取消自动加锁
unique_lock<timed_mutex> locker(mtx, defer_lock);
// 延迟加锁
locker.try_lock_for(chrono::seconds(1));
buy(name);
}
}
void buy(string name)
{
if (ticket > 0)
{
cout << name << "卖票,票号为:" << ticket << endl;
ticket--;
this_thread::sleep_for(chrono::milliseconds(100));
}
}
七、死锁问题
不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的 死锁;出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续;
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
mutex mutexA;
mutex mutexB;
void task1(void);
void task2(void);
int main(void)
{
thread t1(task1);
thread t2(task2);
t1.join();
t2.join();
return 0;
}
void task1(void)
{
mutexA.lock();
cout << "task1抢到A锁" << endl;
this_thread::sleep_for(chrono::seconds(3));
mutexB.lock();
cout << "task1抢到B锁" << endl;
mutexA.unlock();
mutexB.unlock();
}
void task2(void)
{
mutexB.lock();
cout << "task2抢到B锁" << endl;
this_thread::sleep_for(chrono::seconds(3));
mutexA.lock();
cout << "task2抢到A锁" << endl;
mutexA.unlock();
mutexB.unlock();
}
此时,我们可以使用延迟加锁来解决死锁问题。
八、条件变量
在多线程编程中,多个线程可能会因为竞争资源而导致死锁,一旦产生死锁,程序将无法继续运行。为了解决死锁问题,C++ 11 标准引入了条件变量 condition_variable 类模板,用于实现线程间通信,避免产生死锁。
condition_variable 类模板定义了很多成员函数,用于实现进程通信的功能,下面介绍几个常用的成员函数。
wait()
函数:会阻塞当前线程,直到其他线程调用唤醒函数将线程唤醒。当线程被阻塞时,wait() 函数会释放互斥锁,使得被阻塞在互斥锁上的其他线程能够获取互斥锁以继续执行代码。一旦当前线程被唤醒,它就会重新夺回互斥锁。
wait() 函数有两种重载形式,函数声明分别如下所示:
void wait(unique_lock<mutex>& __lock) noexcept;
template<typename _Predicate>
void wait(unique_lock<mutex>& __lock, _Predicate __p);
第一种重载形式称为无条件阻塞,它以 mutex 对象作为参数,在调用 wait() 函数阻塞当前线程时,wait() 函数会在内部自动通过 mutex 对象调用 unlock() 函数解锁,使得阻塞在互斥锁上的其他线程恢复执行。
第二种重载形式称为有条件阻塞,它有两个参数,第一个参数是 mutex 对象,第二个参数是一个条件,只有当条件为 false 时,调用 wait() 函数才能阻塞当前线程;在收到其他线程的通知后,只有当条件为 true 时,当前线程才能被唤醒。
wait_for()
函数:也用于阻塞当前线程,但它可以指定一个时间段,当收到通知或超过时间段时,线程就会被唤醒。wait_for() 函数声明如下所示:
template<typename _Rep, typename _Period>
cv_status wait_for(unique_lock<mutex>& __lock, const chrono::duration<_Rep, _Period>& __rtime);
在上述函数声明中,wait_for() 函数第一个参数为 unique_lock 对象,第二个参数为设置的时间段。函数返回值为 cv_status 类型,cv_status 是 C++ 11 标准定义的枚举类型,它有两个枚举值:no-timeout
和 timeout
。no-timeout
表示没有超时,即在规定的时间段内,当前线程收到了通知;timeout
表示超时。
wait_until()
函数:可以指定一个时间点,当收到通知或超过时间点时,线程就会被唤醒。wait_until() 函数声明如下所示:
template<typename _Duration>
cv_status wait_until(unique_lock<mutex>& __lock, const chrono::time_point<__clock_t, _Duration>& __atime);
在上述函数声明中,wait_until() 函数第一个参数为 unique_lock 对象,第二个参数为设置的时间点。函数返回值为 cv_status 类型。
notify_one()
函数:用于唤醒某个被阻塞的线程。如果当前没有被阻塞的线程,则该函数什么也不做;如果有多个被阻塞的线程,则唤醒哪一个线程是随机的。notify_one() 函数声明如下所示:
void notify_one() noexcept;
在上述函数声明中,notify_one() 函数没有参数,没有返回值,并且不抛出任何异常。
notify_all()
函数:用于唤醒所有被阻塞的线程。如果当前没有被阻塞的线程,则该函数什么也不做。notify_all() 函数声明如下所示:
void notify_all() noexcept;
九、生产者消费者模型
假如有两个进程 A 和 B,它们共享一个 固定大小的缓冲区 ,A 进程产生数据放入缓冲区,B 进程从缓冲区中取出数据进行计算,那么这里其实就是一个生产者和消费者的模式,A 相当于生产者,B 相当于消费者。
在多线程开发中,如果生产者生产数据的速度很快,而消费者消费数据的速度很慢,那么生产者就必须等待消费者消费完了数据才能够继续生产数据,因为生产那么多也没有地方放;同理如果消费者的速度大于生产者那么消费者就会经常处理等待状态,所以为了达到生产者和消费者生产数据和消费数据之间的 平衡,那么就需要一个缓冲区用来存储生产者生产的数据,所以就引入了 生产者-消费者模式。
我们需要保证生产者不会在缓冲区满的时候继续向缓冲区放入数据,而消费者也不会在缓冲区空的时候,消耗数据。当缓冲区满的时候,生产者会进入休眠状态,当下次消费者开始消耗缓冲区的数据时,生产者才会被唤醒,开始往缓冲区中添加数据;当缓冲区空的时候,消费者也会进入休眠状态,直到生产者往缓冲区中添加数据时才会被唤醒。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
using namespace std;
mutex mtx; // 互斥锁用于保护队列
condition_variable cv; // 条件变量用于等待和通知
queue<string> q; // 队列用于存储数据
const int capacity = 10; // 缓冲区容量
bool finished = false; // 标记生产者完成
void productor(string name, string food);
void consumer(string name);
int main(void)
{
thread p1(productor, "星光", "包子");
thread p2(productor, "冰心", "寿司");
thread c1(consumer, "小樱");
thread c2(consumer, "小娜");
p1.join();
p2.join();
c1.join();
c2.join();
return 0;
}
void productor(string name, string food)
{
string data;
srand(time(nullptr));
for (int i = 0; i < 10; i++)
{
this_thread::sleep_for(chrono::seconds((rand() % 3) + 1)); // 模拟延迟
unique_lock<mutex> locker(mtx); // 加锁
cv.wait(locker, [](){ return q.size() < capacity; }); // 等待队列不满
data = "【" + name + "】生产了第 " + to_string(i + 1) + " 个 【" + food + "】";
q.push(data);
cout << data << endl;
cv.notify_all(); // 通知消费者生产好了食物
locker.unlock(); // 解锁
}
finished = true; // 标记生产者完成
cv.notify_all(); // 通知消费者生产者已完成
}
void consumer(string name)
{
string data;
srand(time(nullptr));
while (!q.empty() || !finished)
{
this_thread::sleep_for(chrono::seconds((rand() % 3) + 1)); // 模拟延迟
unique_lock<mutex> locker(mtx); // 加锁
cv.wait(locker, [](){ return !q.empty() || finished;}); // 等待队列非空或生产者未完成
if (!q.empty())
{
data = q.front(); // 获取队头元素
q.pop(); // 弹出队头元素
cout << "【" << name << "】吃了:" << data << endl;
}
cv.notify_all(); // 通知生产者生产食物
locker.unlock(); // 解锁
}
}
在这个例子中,生产者线程生产数据并将其放入队列中,而消费者线程从队列中取出数据并消费。互斥锁用于保护队列,防止多个线程同时访问。条件变量用于线程的等待和通知,生产者在队列满时等待,消费者在队列空时等待。当生产者完成生产时,它会设置一个标志并通知所有消费者,消费者在队列为空且生产者已完成时退出循环。
十、原子操作
在多线程编程中,原子操作是一种不可分割的操作,它在执行过程中不会被其他线程中断。这意味着一旦开始,原子操作就会在所有其他线程看来是瞬间完成的。在 C++ 中,原子操作由 <atomic>
头文件提供,这是 C++ 11 及以后版本的一部分。原子操作对于实现无锁编程和数据结构的并发控制至关重要。它们可以确保对共享数据的操作是安全的,即使在多个线程同时访问该数据时也是如此。
C++ 提供了多种原子类型,如 atomic<int>
, atomic<bool>
, atomic<float>
等,以及对应的指针原子类型,如 atomic<int*>
, atomic<void*>
等。这些原子类型支持一系列原子操作,包括:
store(value)
:将一个值存储到原子对象中。load()
:从原子对象中读取值。exchange(value)
:替换原子对象的值,并返回旧值。compare_exchange_weak(expected, value)
和compare_exchange_strong(exprected, value)
:条件地替换原子对象的值。fetch_add(value)
和fetch_sub(value)
:原子地增加或减少原子对象的值,并返回旧值。operator++
和operator--
:原子地增加或减少原子对象的值。
#include <iostream>
#include <thread>
#include <atomic>
using namespace std;
atomic<int> ticket(100);
void sell_ticket(string name);
int main(void)
{
thread t1(sell_ticket, "窗口1");
thread t2(sell_ticket, "窗口2");
thread t3(sell_ticket, "窗口3");
t1.join();
t2.join();
t3.join();
return 0;
}
void sell_ticket(string name)
{
int temp = 0;
while (ticket.load() > 0)
{
if (ticket.load() > 0) // 获取当前票数
{
temp = ticket.fetch_sub(1); // 原子减法操作,用于减少原子变量的值,并返回原始值
cout << name << "卖票,票号为:" << temp << endl;
this_thread::sleep_for(chrono::milliseconds(1000));
}
else
{
break;
}
}
}