上次更新blog还是生日4.29,这一个月真是忙鼠了,现在有了一个新任务-多线程,搞一下(不加多线程的话我的车车会变成蜗牛🐌的。。。。)
简单示例
例子
当我们谈论多线程时,可以想象成一个人同时在做多个事情,就像一个人既可以同时吃饭又可以同时写作业一样。在C++中,使用多线程可以让我们的程序同时执行多个任务,提高程序的效率和性能。
首先,让我们用一个简单的例子来理解多线程。假设你有一个厨房,里面有一个厨师在煮面条,一个厨师在炒菜,还有一个服务员在端菜。这就相当于一个有多个线程的程序,每个线程负责不同的任务。
在C++中,我们可以使用 <thread>
头文件来创建和管理线程。首先,我们需要定义一个函数,在这个函数中包含我们希望线程执行的任务。然后,我们使用 std::thread
类来创建线程并指定要执行的函数。
让我们来看一个示例代码:
1 |
|
在这个示例中,我们定义了一个简单的函数 printMessage
,并在主函数中创建了一个新线程 t1
,让它执行这个函数。同时,主函数也会继续执行,输出 “Hello from main thread!”。最后我们使用 join()
函数等待线程 t1
的执行完成。
这样,就实现了一个简单的多线程程序。通过合理地使用多线程,我们可以更高效地利用计算机的资源,加快程序的运行速度。当然,在实际编程中,还需要考虑线程之间的同步、互斥访问等问题,以保证程序的正确性和稳定性。
std::thread t1(printMessage);代表着线程开始执行吗
是的,当你创建一个 std::thread
对象并传入要执行的函数,线程就会开始执行这个函数。在你的例子中,std::thread t1(printMessage);
这一行代码就代表着线程开始执行 printMessage
函数。这意味着在运行时,会产生一个新的线程来执行 printMessage
函数中的代码,同时主线程会继续执行后面的代码。
但需要注意的是,线程的启动并不是立即执行 printMessage
函数中的代码,而是在std::thread
对象被创建之后才会执行。如果立即调用 join()
函数,主线程会等待新线程执行完毕再继续往下执行。这种方式保证了线程的同步执行。
因此,在上面提供的示例代码中,std::thread t1(printMessage);
这一行代码会使得新线程开始执行 printMessage
函数,而主线程会继续执行紧随其后的代码,直到遇到 t1.join();
。
在C++中,如果我们使用std::thread来创建线程,线程的启动确实不会立即执行线程函数中的代码。当我们创建一个std::thread对象,指定要执行的线程函数,并调用该std::thread对象的构造函数时,线程对象就会被创建,但线程函数的代码并不会立即执行。实际上线程的实际执行时机取决于操作系统的调度器,在合适的时机会开始执行线程函数。
如果在主线程中立即调用新线程的join()函数,主线程将会被阻塞,直到新线程的执行完成。这种方式确保了线程的同步执行,即主线程会等待新线程执行完毕后才会继续往下执行。在这种情况下,主线程和新线程是同步执行的,主线程起到了等待和协调的作用。
总结:通过调用join()函数实现线程同步执行的流程是这样的,先创建新的线程对象,然后调用join()函数使得主线程等待新线程的执行完成,待新线程执行完毕后,主线程才继续往下执行。这种方式保证了线程的同步执行,特别适用于需要新线程执行完毕后才能继续主线程操作的场景。
进阶实例(互斥锁)
让我们来看一个稍微复杂一点的多线程例子,考虑一个生产者-消费者模型。在这个例子中,有一个生产者线程负责生产数据,然后将数据发送给一个缓冲区,同时一个消费者线程从缓冲区中取出数据进行消费。
1 |
|
在这个例子中,我们使用了一个互斥锁(std::mutex)来保护共享的数据结构(队列buffer),生产者线程往队列中写入数据,消费者线程从队列中读取数据。通过条件变量(std::condition_variable)实现了生产者与消费者之间的同步。
生产者不断地在队列中插入数据,消费者则等待队列中有数据后进行消费。当队列中有数据时,消费者线程被唤醒,消费数据并输出,直至生产者生产完所有数据。整个过程利用互斥锁和条件变量实现了线程之间的同步操作,确保了数据的正确生产和消费。
前三行意思
这三行代码声明了一个整型队列
buffer
,一个互斥锁mtx
和一个条件变量cv
。std::queue<int> buffer;
:这行代码定义了一个STL队列,其中存储整型数据。在生产者和消费者模型中,buffer
用于存储生产者生成的数据,在生产者线程将数据写入队列,消费者线程从队列中取出数据进行消费。std::mutex mtx;
:这行代码定义了一个互斥锁,用于在多线程环境中对共享数据结构进行访问控制。在此例中,mtx
用于保护队列buffer
,确保在任一时刻只有一个线程可以访问队列。std::condition_variable cv;
:这行代码定义了一个条件变量,用于线程间的同步。在此例中,cv
用于通知消费者线程有新的数据可用于消费。消费者线程会通过wait()
来等待生产者通知,一旦有数据可用,它会被notify_one()
通知可以继续执行。
这些机制共同构成了一个生产者-消费者模型,确保在多线程环境下数据的安全访问和同步处理。
为什么确保在任一时刻只有一个线程可以访问队列
在多线程环境中,如果不对共享数据结构(比如队列)进行保护,就可能导致数据竞争(Data Race)的问题。数据竞争指的是多个线程同时访问共享数据,其中至少一个线程对数据进行写操作,而其他线程进行读或写操作,从而导致未定义行为。对于队列这样的数据结构,如果没有互斥访问,会导致以下问题:不确定性:多个线程同时对队列进行操作,可能造成队列中数据的乱序或丢失,导致程序逻辑错误。
数据损坏:当一个线程正在修改队列中的数据时,另一个线程可能同时读取或修改这些数据,造成意外的数据损坏。
死锁:如果多个线程同时尝试对队列进行读写操作而没有恰当的同步机制,可能会出现死锁情况,使得程序无法继续执行。
因此,通过使用互斥锁(
mutex
)来保护队列,在任一时刻只允许一个线程访问队列,可以确保在多线程环境下数据的安全访问,避免数据竞争和可能导致的问题。什么是死锁
死锁(Deadlock)是指在并发系统中,两个或多个进程(线程)因争夺共享资源而进入一种互相等待的状态,导致它们都无法继续执行下去的情况。在死锁情况下,每个进程都在等待其他进程释放资源,而同时又不愿意主动释放自己当前已经占有的资源,从而形成了一种僵局。死锁是多线程编程中常见的并发问题,需要通过合理的设计和实现来避免和解决死锁问题。std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 模拟生产数据的耗时操作
这行代码是使用C++标准库提供的线程库
<thread>
中的std::this_thread::sleep_for
函数,用来模拟在生产数据过程中的耗时操作。让我们来详细解释一下这行代码的作用:std::this_thread::sleep_for
:这是C++标准库中用于线程操作的静态成员函数,允许当前线程休眠一段时间。因为是this_thread
所以是当前线程。std::chrono::milliseconds(500)
:这里使用std::chrono::milliseconds
来创建一个表示时间间隔的对象,参数为500,表示500毫秒(即0.5秒)的时间间隔。std::chrono
是C++标准库提供的日期和时间库,用于处理时间相关的操作。// 模拟生产数据的耗时操作
:这是一行注释,说明了这行代码的目的,即模拟在生产数据过程中的耗时操作。通常在生产数据或其他需要等待的操作中,为了模拟真实场景的延迟,会使用类似的休眠操作来延迟线程的执行。
因此,这行代码的作用是让当前线程休眠500毫秒,模拟生产数据过程中的耗时操作,以便更好地测试和理解程序的行为。
std::lock_guard < std::mutex > lock(mtx);
std::lock_guard<std::mutex> lock(mtx);
是C++中使用RAII机制来管理互斥锁(std::mutex
)的典型语法格式。这行代码的作用是创建一个lock_guard
对象并用其构造函数锁住一个std::mutex
对象mtx
。具体解释如下:
std::lock_guard
是一个模板类,用于管理锁对象(互斥锁)。它会在构造时锁住给定的互斥锁,在析构时解锁互斥锁,实现了RAII机制。<std::mutex>
是模板参数,表示std::lock_guard
将要管理的互斥锁的类型是std::mutex
。lock
是std::lock_guard
对象的名称,可以根据需要命名。(mtx)
表示用mtx
(一个名为mtx
的std::mutex
对象)来初始化lock_guard
对象,即在构造lock_guard
对象时锁住mtx
。
std::lock_guard
是C++标准库提供的一个RAII(资源获取即初始化)风格的互斥量封装类,通过它可以在一个作用域中保持互斥锁的持有状态。在这段代码中,std::lock_guard<std::mutex> lock(mtx);
的作用是使用互斥量mtx
对临界区进行加锁,确保对共享数据结构buffer
的访问是线程安全的。让我们来详细解释其作用:RAII机制:RAII(Resource Acquisition Is Initialization)是一种C++编程范式,它通过对象的生命周期来管理资源的获取和释放,确保资源在对象构造时被正确获取,而在对象析构时被自动释放。这种机制的核心思想是利用局部对象的生命周期控制资源的获取和释放,从而确保资源的正确管理,提高代码的可靠性和可维护性。
下面是RAII机制的工作原理和优势:
资源获取即初始化:当一个对象被创建时,同时获取所需的资源,例如内存、文件句柄、互斥锁等,这样资源的获取和初始化是统一的操作。
资源的自动释放:当对象离开其作用域时(比如函数返回、作用域结束等),其析构函数会被调用,负责释放已获取的资源,无论是正常执行还是异常发生,资源都会被正常释放。
避免资源泄漏和确保资源管理:通过RAII机制,程序员无需手动管理资源的获取和释放,减少了因忘记释放资源而导致的内存泄漏或资源泄漏问题。
异常安全性:RAII可确保在异常发生时,资源能够被正确释放,避免资源泄漏,同时保证程序的安全性。
提高代码的可维护性和可读性:RAII使资源管理的逻辑更加清晰明了,代码更易于理解和维护,同时降低了在多线程环境下出现的死锁等问题。
在C++中,标准库中的智能指针(
std::unique_ptr
、std::shared_ptr
等)和锁(std::lock_guard
、std::unique_lock
等)都是基于RAII机制设计的。通过合理利用RAII机制,可以有效管理资源,提高代码的健壮性和可靠性。加锁:当
std::lock_guard
对象lock
被创建时,它会在构造函数中对互斥量mtx
进行加锁操作,即锁住mtx
。这样就确保了在lock_guard
对象的作用域内(在这个括号内)对buffer
的访问是受到保护的,其他线程无法同时修改buffer
的内容,避免了数据竞争和不一致性。在这个括号内
指的是std::lock_guard
对象lock
的作用域范围,即lock
对象的生命周期。在C++中,作用域用花括号{}
表示,也叫做代码块。当std::lock_guard
对象lock
的作用域范围结束(即超出了花括号的范围),该对象的析构函数会被调用,从而自动释放锁。这种方式确保了在lock
对象的作用域内,互斥锁被保持,从而保护了共享资源(如buffer
)的访问并避免了数据竞争和不一致性的问题。
因此,通过在花括号内创建
std::lock_guard
对象并锁住互斥量,可以确保只有一个线程能够访问或修改被保护的共享资源,从而提高多线程程序的安全性。- 解锁:当
lock_guard
对象的作用域结束时,会调用其析构函数,在析构函数中会自动解锁互斥量mtx
,即释放mtx
的锁,使得其他线程可以访问共享资源。
通过使用
std::lock_guard
和互斥量,可以简化了对临界区的加锁和解锁过程,并且可以避免因为忘记解锁导致的死锁情况。这样的操作保证了对临界区资源的访问是互斥的,确保了多线程环境下的数据安全性。cv.notify_one();
在多线程编程中,cv.notify_one()
是用来通知等待在条件变量上的某个线程继续执行的函数。当某个线程调用cv.wait()
方法并在条件变量上等待时,其他线程可以通过调用cv.notify_one()
或cv.notify_all()
来通知等待的线程可以继续执行。具体来说,
cv.notify_one()
会唤醒在条件变量上等待的一个线程(如果有的话),让其可以从等待状态中醒来并继续执行。如果没有线程在条件变量上等待,那么调用cv.notify_one()
也不会有任何效果。在多线程协作中,通常会结合条件变量和互斥量来实现线程之间的同步操作。当一个线程修改了共享数据,并希望通知其他线程有关此数据的改变时,可以使用条件变量来实现线程之间的同步通信。通常的流程是,先获得互斥锁,然后修改共享数据,再通知等待在条件变量上的线程,最后释放互斥锁。
在这段示例代码中,生产者线程和消费者线程通过条件变量
cv
和互斥量mtx
实现了线程之间的同步通信。生产者线程通过notify_one()
方法通知消费者线程,告知其有新的数据可以被消费。具体来说,cv.notify_one()
的作用是:- 当生产者线程生产了一个数据后(通过
buffer.push(i)
),它会立即通知等待在条件变量cv
上的消费者线程。 - 在消费者线程中,当它通过
cv.wait(lock, [] { return !buffer.empty(); });
进入等待状态时,会自动释放mtx
互斥量并等待,直到生产者线程调用cv.notify_one()
来唤醒它。 - 一旦消费者线程被唤醒,它会重新获取
mtx
互斥量并继续执行,从buffer
中取出数据消费。 - 生产者线程每生产一个数据,就会唤醒一个等待的消费者线程。如果有多个消费者线程在等待,只有一个会被唤醒。
综上所述,
cv.notify_one()
的作用是唤醒一个等待在条件变量上的线程,用来提示该线程可以继续执行,适用于协调多线程之间的操作顺序和数据传递。- 当生产者线程生产了一个数据后(通过
为什么一个用std::lock_guard一个用std::unique_lock
在上述代码中,producer()
函数使用了std::lock_guard
来锁定互斥量,而consumer()
函数则使用了std::unique_lock
。这种选择并非绝对,而是根据不同的需求和情境来决定使用哪种锁定方式。std::lock_guard
在 producer 函数中的使用:
- 在
producer()
函数中,生产者生产数据到缓冲区,并在数据生产完成后立即释放互斥量。由于在生产者生产完数据后就会释放互斥量,因此使用std::lock_guard
而不是std::unique_lock
更加简洁和合适。std::lock_guard
自动锁定互斥量,并在作用域结束时自动释放互斥量,非常适合这种简单的锁定和释放情况。
std::unique_lock
在 consumer 函数中的使用:
- 在
consumer()
函数中,消费者需要等待缓冲区中有数据可消费时才能继续执行。为了支持在等待期间手动解锁互斥量并等待条件变量,这里使用了std::unique_lock
。std::unique_lock
提供了更灵活的加锁和解锁操作,同时支持条件变量的等待。在等待缓冲区有数据可消费时,我们使用cv.wait(lock, [] { return !buffer.empty(); });
来等待条件变量满足。
综上所述,选择使用
std::lock_guard
或std::unique_lock
取决于具体的应用场景和需求。在简单的自动锁定和释放互斥量情况下,可以使用std::lock_guard
;而在需要更多灵活性和条件变量支持的情况下,可以选择使用std::unique_lock
。在生产者消费者模型中,生产者和消费者可能有不同的需求,因此可以根据具体情况选择不同的锁定方式。cv.wait(lock, [] { return !buffer.empty(); });
cv.wait(lock, [] { return !buffer.empty(); });
这行代码是在consumer()
函数中使用条件变量等待的部分。让我们详细解释这段代码的作用和具体执行流程:cv.wait(lock, condition)
:这是条件变量cv
的等待函数调用。在这里,lock
是一个std::unique_lock<std::mutex>
对象,通过将lock
传递给cv.wait
,会在等待期间将互斥量解锁,以便其他线程可以在该互斥量上执行操作。condition
是一个 lambda 函数,用于指定等待条件。在这种情况下,cv.wait
会在等待期间检查 lambda 函数返回的布尔值,只有在 lambda 函数返回true
时才会继续执行。[] { return !buffer.empty(); }
:这是一个 lambda 函数,它检查buffer
是否为空。条件变量的wait
操作依赖于用户提供的条件函数。在这里,条件函数检查buffer
是否为空,如果为空,则条件为假,cv.wait
会一直等待,直到条件变为真。一旦条件为真,cv.wait
将继续执行,即互斥量被重新锁定,并且线程继续执行下去。执行流程:
- 当消费者线程开始执行
consumer()
函数时,首先会创建一个std::unique_lock<std::mutex>
对象lock
,该对象在实例化时会锁定其关联的互斥量mtx
。 - 接着,消费者线程调用
cv.wait(lock, condition)
,这会导致线程释放互斥量的锁并等待,直到condition
返回true
。 - 在等待过程中,其他线程可以获得互斥量的锁并修改共享数据,但由于条件未满足,消费者线程会一直保持等待状态。
- 当生产者线程向
buffer
中加入数据并调用cv.notify_one()
通知时,条件!buffer.empty()
变为真,这会导致条件变量通知消费者线程继续执行。 - 消费者线程被唤醒后,会重新获得互斥量的锁,这时可以安全地从
buffer
中取出数据进行消费,然后继续执行后续的操作。
- 当消费者线程开始执行
总之,通过使用条件变量和 lambda 函数等待特定条件,可以实现线程间的同步和协调,确保消费者在有数据可消费时才执行消费操作,从而有效避免了竞态条件和数据不一致的问题。
lambda 表达式
用于定义函数对象:[]
:lambda 表达式的开头以一对方括号开始。在这里,[]
表示 lambda 表达式不捕获任何外部变量。这意味着 lambda 表达式只能访问传递给它的参数,而不能访问外部作用域的变量。{}
:lambda 表达式的主体使用一对大括号来定义,在大括号内部是 lambda 表达式的具体实现。
互斥锁
在多线程编程中,为了避免多个线程同时访问共享资源而引起的竞争条件和数据不一致性问题,我们使用互斥锁(Mutex,全称Mutual Exclusion)。互斥锁是一种同步原语,它允许线程在进入临界区(对共享资源操作的临界区域)前先获取锁,执行完后再释放锁,从而确保在同一时刻只有一个线程能进入临界区进行操作。
详细解释互斥锁包括以下几点:
锁的状态: 互斥锁有两种状态,分别是锁定(locked)和解锁(unlocked)。线程在进入临界区之前需要先获取锁(即将锁定状态设定为locked),执行完后再释放锁(将状态设定为unlocked)。
互斥性: 互斥锁的特性确保了同一时刻只有一个线程能够获取锁,其他线程需要等待当前持有锁的线程释放锁后才能继续执行。这样可以避免多个线程同时访问共享资源导致的数据竞争和不一致性问题。
使用方法: 在多线程代码中使用互斥锁时,一般会使用
std::mutex
或类似的互斥锁类型。典型的用法是在临界区开始之前调用lock()
方法获取锁,在临界区结束后调用unlock()
方法释放锁。死锁: 如果在多线程代码中使用互斥锁不当,可能会导致死锁(Deadlock)的问题。死锁指的是多个线程相互等待对方释放资源而无法继续执行的情况,因此在使用互斥锁时要注意避免造成死锁。
总之,互斥锁是一种重要的同步工具,用于在多线程环境下控制对共享资源的访问,确保线程安全性并避免竞争条件和数据不一致性问题的发生。在正确使用互斥锁的情况下,可以有效地保证多线程程序的正确性和稳定性。
临界区(对共享资源操作的临界区域)
临界区是指在多线程程序中,多个线程共享的某一段代码或数据区域,这里的代码或数据可能会被多个线程同时访问和操作。在临界区中,如果多个线程同时对共享资源进行读写操作,就会发生竞态条件(Race Condition),导致程序出现错误或产生不确定的结果。
让我们通过一个生动的比喻来解释临界区:
假设有一个小咖啡厅只有一个咖啡机可以制作咖啡。多个服务员(线程)需要制作咖啡时,他们需要先检查咖啡机是否空闲。如果咖啡机正在被使用,那么其他服务员必须等待直到当前制作完成并释放咖啡机。在这个过程中,检查咖啡机状态、占用咖啡机、释放咖啡机的这一段代码就是临界区。只能有一个服务员进入临界区,占用咖啡机,制作咖啡,防止出现多个服务员同时操作导致的错误。
所以,临界区是指在多线程环境下,需要同步访问共享资源的代码区域或数据区域,为了避免竞态条件,需要通过互斥锁等同步机制来确保同一时刻只有一个线程能够访问临界区,保证数据的正确性和一致性。通过合理管理临界区,可以有效避免多线程程序中的各种潜在问题,确保程序的正确性和稳定性。
复杂一点点的例子
以下是一个更复杂的多线程示例,其中包含两个线程,一个是生产者线程,一个是消费者线程,它们之间通过共享的缓冲区进行通信:
1 |
|
在这个示例中,生产者线程负责向缓冲区中放入数据,而消费者线程则负责从缓冲区中取出数据。生产者线程会一直生产数据直到生产完20个数据为止。消费者线程会在生产者线程生产数据时,从缓冲区中消费数据,并且当生产者线程结束时也会结束。
通过使用互斥锁和条件变量来实现对缓冲区的访问控制和线程通信。生产者线程会根据缓冲区的状态来决定是否生产数据,而消费者线程也会根据缓冲区的状态来决定是否消费数据。在主函数中,调用了 join()
来等待生产者线程和消费者线程的结束。
输出:
1 | Produced: 0 |
当加上sleep_for后:
1 |
|
输出:
1 | Produced: 0 |
在生产者线程中使用this_thread::sleep_for(std::chrono::milliseconds(500));
来模拟生产数据的耗时操作是为了模拟实际生产数据可能存在的耗时情况,而不是立即连续生产数据。这种模拟有几个关键的影响:
模拟实际情况:在实际情况下,生产数据可能涉及到一些繁重的计算或I/O操作,这可能导致需要一定的时间来生成数据。通过这种模拟,可以更好地反映现实情况下的生产数据过程。
线程调度:使用
sleep_for
会使得当前线程在指定的时间内暂停执行,其他线程有机会继续执行。这有助于模拟并发环境中不同线程之间的调度和竞争。避免资源争夺:由于生产者线程在生产数据时会暂停一段时间,消费者线程有机会访问和处理已生产的数据,而不会因为生产速度过快导致队列溢出或数据丢失。
如果去掉this_thread::sleep_for(std::chrono::milliseconds(500));
部分,生产者线程将会变得非常快速,可能导致生产的数据迅速填满队列,消费者线程无法及时消费,从而导致队列溢出。因此,这段代码中的延时操作对于正确模拟生产者和消费者之间的交互过程和线程调度是非常重要的。
为什么done=true后面要加consumeCondition.notify_one();
在典型的生产者消费者模型中,
done=true
通常表示生产者已经完成了生产任务,没有更多的数据可供消费者消费。当done
被设置为true
时,消费者线程需要被唤醒并退出循环,以避免不必要的等待和资源浪费。为了实现这一点,通常会使用条件变量(condition variable)来实现线程的等待和唤醒。在设置
done=true
之后,通过调用consumeCondition.notify_one()
来唤醒等待在consumeCondition
条件变量上的一个消费者线程。这样做可以确保消费者线程在done=true
之后能够立即被唤醒,检查条件并退出循环。没有这一步的唤醒操作,消费者线程可能会一直阻塞在条件变量上,无法及时退出循环,导致资源浪费或程序逻辑错误。因此,通过在
done=true
之后调用consumeCondition.notify_one()
来唤醒消费者线程,确保了生产者完成生产后消费者能够及时退出循环,有效地管理线程的执行顺序和资源的利用。condition_variable是什么
std::condition_variable
是 C++ 标准库中用来实现多线程同步的一种机制。它允许一个或多个线程等待,直到某个条件得到满足后再继续执行。在多线程编程中,我们经常需要一种方式来进行线程之间的通信和同步,std::condition_variable
就是为此而设计的。在使用
std::condition_variable
时通常和std::mutex
配合使用,具体步骤如下:- 使用
std::mutex
来保护需要等待的条件变量和共享资源。 - 当条件不满足时,线程通过
std::condition_variable
的wait()
方法释放锁并进入等待状态,直到其他线程通知条件满足。 - 当其他线程满足了条件后,调用
std::condition_variable
的notify_one()
或notify_all()
方法来唤醒等待的线程。
通过
std::condition_variable
,我们可以实现线程之间的有效协作和同步,避免了线程忙等待,提高了程序的效率和性能。- 使用