C++之多线程编程
进程与线程
在开始多线程编程的学习之前,我们必须先要了解一下进程和线程之间的关系。
进程(Process)
进程是操作系统进行资源分配和调度的一个独立单位。它是程序在操作系统中的一次执行过程,拥有独立的地址空间、内存、数据栈以及其他记录其运行轨迹的辅助数据。
每个进程都有自己独立的地址空间,一个进程崩溃不会直接影响到其他进程。进程作为资源分配的基本单位,需要较多的资源(如内存、处理器时间)。进程间通信(IPC)需要特定的操作系统支持,如管道、消息队列、共享内存等,相对成本较高。
通常,当你启动一个程序时,操作系统为该程序创建一个进程。这个进程提供了程序运行所需的资源和环境。因此,在最简单的情况下,可以说一个程序在任何给定时刻运行在一个进程内。然而,有些程序可能被设计为多进程应用,意味着它们可以启动额外的进程来执行特定的任务或提供并发处理能力。这些进程可能运行相同的程序代码,或者运行程序的不同部分。因此,一个程序在运行时可能会使用多个进程。
上图为Windows任务管理器中进程的信息,可以看到大部分软件仅仅使用了一个进程来运行。
线程(Thread)
线程是进程中的一个实体,被系统独立调度和分派的基本单位。线程自身基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如执行栈、程序计数器),但它可以与同属一个进程的其他线程共享进程所拥有的全部资源。
线程的创建和切换比进程更快,资源需求更少。同一进程内的线程共享进程资源,如内存和文件描述符等。由于共享相同的地址空间,线程间的通信不需要操作系统介入,可以直接读写进程数据或通过条件变量和互斥量等同步机制来交流。
多线程是现代编程中一种常用的技术,它允许程序同时执行多个任务。这是通过在单个程序内创建多个线程来实现的,每个线程可以并行处理任务。由于所有线程共享同一进程的内存空间,它们可以高效地交换信息和状态。
多线程有以下优点:提高应用程序的响应性、更高效的CPU使用、 改善程序结构、资源共享。
同时具备以下的缺点:
- 同步复杂性:多线程程序中最大的挑战之一是确保线程安全,即在多个线程访问共享资源时保护这些资源,避免数据冲突和不一致性。这通常需要复杂的同步技术,如互斥锁(mutexes)、信号量(semaphores)等。
- 调试困难:多线程程序的调试比单线程程序更加复杂,因为线程的非确定性执行顺序和潜在的竞态条件可以导致难以重现和诊断的错误。
- 资源竞争:如果多个线程试图同时访问相同的资源,可能会导致性能下降,因为线程可能需要等待其他线程释放资源。
- 死锁:多线程程序可能会遇到死锁,即两个或多个线程相互等待对方释放资源,从而导致它们都停止执行。避免和解决死锁是多线程编程中的一个重要挑战。
进程与线程的联系
线程存在于进程之中,是进程的执行单元。没有进程就没有线程。进程至少包含一个线程,这个线程通常被称为主线程。从层次结构上来看,进程可以被视为容器或执行环境,线程则是在这个环境中执行的实体。
进程是资源分配的基本单位。操作系统给每个进程分配独立的内存空间、文件句柄、设备和其他资源。线程共享其所属进程的资源。在同一个进程中的线程可以直接访问进程级的全局内存、静态变量等,这使得线程间的数据交换和通信更为方便。
虽然进程是资源分配的基本单位,但线程是操作系统调度的基本单位。这意味着CPU分配时间片给线程,而非进程。因此,线程才是程序执行的最小单位。一个进程可以包含多个线程,这些线程可以利用多核处理器的优势同时执行,提高应用程序的执行效率。
创建进程的开销远大于创建线程。进程创建涉及到复制父进程资源和创建独立地址空间等操作,而线程创建主要是分配少量资源如栈。由于线程共享其父进程的资源,线程的管理和上下文切换的开销也小于进程。这使得线程更适合执行规模较小、需要频繁创建和销毁的任务。
C++中的线程库
C++11标准引入了一个全新的线程库,这是C++语言对多线程编程的原生支持的一部分。C++11线程库的核心组件包括:
-
std::thread:这是库的中心类,代表一个单独的线程。每个
std::thread对象与一个执行线程相关联(如果有的话)。通过创建std::thread对象来启动新线程,并通过调用join()或detach()方法来管理它。 -
互斥量(Mutexes):C++11提供了几种互斥量,包括
std::mutex用于保护共享数据,防止多个线程同时访问。其他类型的互斥量包括std::recursive_mutex、std::timed_mutex和std::recursive_timed_mutex。 -
条件变量(Condition Variables):
std::condition_variable和std::condition_variable_any用于线程间的同步,允许一个或多个线程在某条件成立时被唤醒。 -
期货(Futures)和承诺(Promises):
std::future和std::promise提供了一种从异步操作中获取结果的方法。std::async可以用来启动一个异步任务,它返回一个std::future对象,用于获取异步操作的结果。 -
原子操作(Atomic Operations):C++11引入了
std::atomic模板类,用于执行无锁的原子操作,这对于某些高性能或低等级的并发编程场景非常有用。
线程管理
std::thread
std::thread是C++11标准库中提供的用于多线程编程的类,它使得在C++程序中创建、控制和管理线程变得更加容易。通过std::thread,你可以创建新的线程并指定要执行的函数,也可以控制线程的行为,如等待线程执行完毕(join)、分离线程(detach)等操作。
以下是std::thread类的一些重要特点和用法:
- 创建线程:通过提供一个函数或者可调用对象(如lambda表达式)作为参数,可以创建一个新的线程。
#include <thread>
void myFunction() {
// 线程要执行的任务
}
int main() {
std::thread t(myFunction); // 创建线程并执行myFunction函数
t.join(); // 等待线程执行完毕
return 0;
}
- 线程管理:可以通过
join()函数等待线程执行完毕,也可以通过detach()函数分离线程,使得线程在后台运行。
std::thread t(myFunction);
t.join(); // 等待线程执行完毕
// 或者分离线程
std::thread t(myFunction);
t.detach(); // 线程被分离,主线程不会等待其执行完毕
- 传递参数:可以通过在创建线程时传递参数来向线程函数传递参数。下面是一个值传递的例子:
#include <iostream>
#include <thread>
void printMessage(const std::string& message) {
std::cout << message << std::endl;
}
int main() {
std::string message = "Hello from thread!";
std::thread t(printMessage, message);
t.join();
return 0;
}
同样的,也可以传递引用给另一个线程,需要使用关键字std::ref。
#include <iostream>
#include <thread>
// sleep函数在windows下使用windows.h头文件,在linux下使用unistd.h
#include <windows.h>
void thread1Func(int& num)
{
while (true)
{
Sleep(1000);
std::cout << "Thread1:" << num++ << std::endl;
}
}
void thread2Func(int& num)
{
while (true)
{
Sleep(1200);
std::cout << "Thread2:" << num++ << std::endl;
}
}
int main() {
int num = 42;
std::thread t1(thread1Func, std::ref(num));
std::thread t2(thread2Func, std::ref(num));
t1.join();
t2.join(); // 主线程会等待这两个线程结束后才结束,但是这两个线程不会结束,所以主线程也不会结束。
return 0;
}
上面的示例程序就是两个线程交替输出num的值,并且每次输出都会+1。
来看看下面这一道题:
#include <iostream>
#include <thread>
// sleep函数在windows下使用windows.h头文件,在linux下使用unistd.h
#include <windows.h>
void thread1Func()
{
Sleep(1000);
std::cout << "Thread1 ends!" << std::endl;
}
void thread2Func()
{
Sleep(1200);
std::cout << "Thread2: ends!" << std::endl;
}
int main() {
int num = 42;
std::thread t1(thread1Func);
std::thread t2(thread2Func);
t1.detach();
t2.detach();
Sleep(1100);
return 0;
}
请问控制台会输出什么?答案是只输出:Thread1 ends!。在这个示例程序中,主线程只会运行1.1秒,运行完线程1需要1秒,运行完线程2需要1.2秒,主线程等不了线程2运行完程序就会结束(线程2可能仍然存在)。
获取线程的ID
要获取当前线程的ID,可以使用std::this_thread::get_id()函数。这个函数返回一个std::thread::id对象,表示当前线程的ID。你可以将这个ID打印出来以查看当前线程的ID。
下面是一个简单的示例代码,演示如何获取当前线程的ID并将其打印出来:
#include <iostream>
#include <thread>
void printThreadId() {
std::thread::id this_id = std::this_thread::get_id();
std::cout << "Thread ID: " << this_id << std::endl;
}
int main() {
std::thread t(printThreadId);
t.join();
return 0;
}
在这个示例中,printThreadId函数会获取当前线程的ID,并在控制台上打印出来。
同步机制
互斥量(Mutex)
在C++多线程编程中,互斥量(Mutex)是一种用于保护共享资源不被多个线程同时访问的同步原语。互斥量可以确保在任何时刻只有一个线程可以访问共享资源,从而避免数据竞争和并发访问的问题。
在C++中,互斥量通常使用std::mutex类来表示,通过在关键代码段中锁定和解锁互斥量来实现线程安全。
来看下面这个程序:
#include <iostream>
#include <thread>
int shared_data = 0;
void incrementSharedData() {
for (int i = 0; i < 10000; ++i) {
shared_data++;
}
}
int main() {
std::thread t1(incrementSharedData);
std::thread t2(incrementSharedData);
// 两个线程争用shared_data
t1.join();
t2.join();
std::cout << "Final shared data value: " << shared_data << std::endl;
return 0;
}
输出结果为:Final shared data value: 10003,不唯一,可见这个结果并不正确,这是因为两个并行的线程同时对shared_data进行了修改,这样导致了错误。
为了解决这个问题,可以使用mtx.lock()和mtx.unlock(),用于保护在其调用范围内的代码,也就是在mtx.lock()和mtx.unlock()之间的代码块,通常称为临界区(Critical Section)
修改过的代码如下:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
int shared_data = 0;
void incrementSharedData() {
for (int i = 0; i < 10000; ++i) {
mtx.lock();
shared_data++;
mtx.unlock();
}
}
int main() {
std::thread t1(incrementSharedData);
std::thread t2(incrementSharedData);
t1.join();
t2.join();
std::cout << "Final shared data value: " << shared_data << std::endl;
return 0;
}
输出代码如下:Final shared data value: 20000。mutex的对于共享数据的处理逻辑如下:当一个线程调用mtx.lock()时,如果互斥量当前没有被其他线程锁定,那么该线程会获得互斥量的所有权,可以进入临界区(即被互斥量保护的代码段)执行操作。如果此时有其他线程已经锁定了互斥量,那么调用mtx.lock()的线程会被阻塞,直到其他线程释放了互斥量。通过在关键代码段内使用互斥量的加锁和解锁操作,可以确保同一时刻只有一个线程可以访问共享资源,从而避免了数据竞争问题,保证了线程安全性。
评论区