你好!作为一名C++程序员,你一定听说过”进程“、”线程“、”C++并发编程“这些术语。它们是构建现代复杂应用程序的基石,也是优化程序性能、提高资源利用率的关键。然而,对于许多初学者来说,这些概念可能会显得有些抽象和难以捉摸。
今天,我们就来一次深度刨析,从最基础的概念开始,一步步深入到C++中多任务编程实践,最终触及当下流行的协程技术。准备好了吗?让我们开始吧!
第一站:理解程序、进程–”静“与”动“的艺术
在深入探讨线程和协程之前,我们首先需要明确”程序“和”进程“这两个概念。
程序:你可以把程序想象成一个静态的、存储在磁盘上的指令集合。比如你用C++写好并编译成a.out(Linux)或example.exe(Windows),这就是一个程序。它就像是一张乐谱,本身不会发出声音,只是一系列指令的排列。
进程:当你的程序被加载到内存中,并由操作系统(os)开始执行时,它就变成了一个动态的、正在运行的实例。这就是一个”活“的实体,拥有自己独立的内存空间、文件句柄、系统资源等。每个进程都像一个独立的”运行“环境或”工作坊“,它有自己的”工具箱“和”工作台“,可以独立完成任务,互不干扰。C++视角下:
当你编译并运行一个C++程序时,例如:
#include <iostream>
#include <unistd.h> // for getpid() on Linux/macos
int main(){
std::cout << "Hello from my program!" << std::endl;
std::cout << "My process ID is: " << getpid() << std::endl; //打印进程ID
//...程序的后续逻辑
return 0;
}
当你执行./a.out时,操作系统会创建一个新的进程来执行你的main函数。这个进程拥有自己的虚拟地址空间,里面的代码、数据、堆、栈都是独立的。进程的特点:
- 独立性:每个进程都有自己独立的内存空间,一个进程的崩溃通常不会影响其他的进程。
- 资源拥有者:进程是系统资源分配的基本单位,它拥有程序代码、数据、文件、设备等资源。
- 开销大: 创建和销毁进程的开销相对较大,进程之间的通信(IPC)需要特定的机制(如管道、共享内存、消息队列等),且相对复杂。
第二站:理解线程–进程内的”分身术“
一个进程往往需要完成多个任务。如果所有任务都串行执行,那么效率会很低,尤其当某个任务需要等待I/O(读取文件、网络通信)时,整个进程就会阻塞。为了解决这个问题,引入了”线程“的概念。
线程:线程时进程内的一个执行单元,是CPU调度的基本单位。一个进程可以包含一个或多个线程。你可以把线程想象成进程内部的”工人“。一个工作坊(进程)里可以有一个工人(单线程),也可以有多个工人(多线程),这些工人都共享工作坊里的工具和设施(进程的内存空间和资源)。线程的特点(与进程对比):
- 共享资源:同一个进程内的所有线程共享进程的内存空间、文件描述等资源。这意味着它们可以直接访问进程的数据,通讯效率高。
- 独立执行流:每个线程都有自己独立的程序计数器、栈和寄存器,确保它们之间可以独立执行不同的代码路径。
- 轻量级:创建和销毁线程的开销比进程小的多。线程之间的切换(上下文切换)也比进程之间切换更快。
- 并发性:多线程可以实现并行或并发执行。在多核CPU上,多个线程可以真正地并行运行;在单核CPU上,操作系统通过时间片轮转,使得多个线程并发执行,看起来像是在同时运行。
C++视角下(std::thread):
C++11引入了标准库<thread>,使得多线程编程变得非常便捷。
#include <iostream>
#include <thread> //引入线程库
#include <vector>
#include <chrono> // for std::chrono::seconds
//线程要执行的函数
void task_function(int id){
std::cout << "Thread " << id << " started." << std::endl;
//模拟耗时操作
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "Thread " << id << " finished." << std::endl;
}
int main(){
std::cout << "Main thread started." << std::endl;
//创建并启动两个线程
std::thread t1(task_function,1); //线程1执行task_function(1)
std::thread t2(task_function,2); //线程2执行task_function(2)
//主线程可以继续做自己的是事情
std::cout << "Main thread is doing something else..." << std::endl;
//等待子线程完成(join)
//join会阻塞主线程,直到对应的子线程执行完毕
t1.join();
t2.join();
std::cout << "All threads finished. Main thread existing." << std::endl;
return 0;
}
编译运行:
g++ -std=C++11 -pthread example.cpp -o example
(在Linux/MacOS上需要链接pthread库,-pthread会自动处理),运行结果如下:
Main thread started.
Thread 1 started.
Thread 2 started.
Main thread is doing something else...
Thread 1 finished.
Thread 2 finished.
All threads finished. Main thread exiting.
可以看到,Thread 1 started. 和Thread 2 started.是几乎同时打印的,这表明两个线程在并发执行。Main thread is doing something else…也在它们执行的同时进行。线程同步与互斥:
由于线程共享内存,这带来了便利,同时也带来了数据竞争的问题。当多个线程同时读写同一块共享数据时,如果没有适当的同步机制,程序的行为将是不可预测的。这就是需要使用互斥量(std::mutex)、条件变量(std::condition_variable)、读写锁(std::shared_mutex)、原子操作(std::atomic)等同步机制的原因。示例:使用std::mutex保护共享数据
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> //引入互斥量
#include <numeric> //for std::accumulate
long long global_sum = 0;
std::mutex mtx; //定义一个互斥量
void add_to_sum(int start, int end){
for(int i = start; i <= end; ++i){
//使用lock_guard,它在构造时加锁,析构时自动解锁
std::lock_guard<std::mutex> guard(mtx);
global_sum += i;
}
}
int main(){
std::cout << "Calculing sum with threads..." << std::endl;
const int num_threads = 4;
const int total_numbers = 100000;
const int numbers_per_thread = total_numbers / num_threads;
std::vector<std::thread> threads;
for(int i= 0; i < num_threads; ++i){
int start = i * numbers_per_thread + 1;
int end = (i == num_threads - 1) ? total_numbers : (i + 1) * numbers_per_thread;
threads.emplace_back(add_to_sum,start,end);
}
for(std::thread& t : threads){
t.join();
}
long long expected_sum = (long long)total_numbers * (total_numbers + 1) / 2;
std::cout << "Expected sum: " << expected_sum << std::endl;
std::cout << "Calculated sum: " << global_sum << std::endl;
if(global_sum == expected_sum){
std::cout << "Sums match! Data was protected correctly." << std::endl;
}else{
std::cout << "Sums mismatch! Something went wrong with synchronization." << std::endl;
}
return 0;
}
在这个例子中,每一次对global_sum的修改都被std::lock_guardstd::mutex guard(mtx);所保护。这确保了在任何给定时间,只有一个线程能够访问并修改global_sum,从而避免了数据竞争,保证了计算的正确性。
第三站:理解协程–更轻量的“协作式”并发
进程和线程解决了并发执行的问题,但它们依然有其局限性:
- 线程切换开销:尽管比进程小,但线程上下文切换仍然需要保存和恢复寄存器、TLB等信息,并可能导致缓存失效,这在大量线程切换时会带来性能消耗。
- 资源消耗:每个线程都需要一定的栈空间和内核资源。
- 复杂性:多线程编程中,线程同步(锁、信号量等)的引入使得程序逻辑变得复杂,容易出错(如死锁、活锁、饥饿)。
为了进一步提高并发效率和简化编程模型,协程应运而生。
协程:协程是一种用户态的、非抢占式(协作式)的并发模型。它允许函数(或任务)在执行过程中暂停,然后将控制权主动交还给调用者或调度器,并在需要时从暂停点恢复执行。
- 用户态:协程的切换完全发生在用户空间,不需要操作系统的参与,因此没有内核态/用户态切换的开销。
- 非抢占式/协作式:核心区别在于,线程由操作系统调度器进行强行切换,而协程必须主动放弃CPU控制权。如果一个协程不主动暂停(yield),它会一直运行直到完成,可能阻塞其他协程。这就像一个团队,成员们约定好轮流发言,而不是由领导强制打断。
- 更轻量:协程通常比线程拥有更小的栈空间,创建和切换开销极小。
协程的优势:
- 极高的并发性:可以轻松创建成千上万个协程。
- 更低的开销:切换成本极低。
- 简化异步编程:通过同步的写法实现异步逻辑,避免回调地狱,可读性更强。
C++视角下(C++20):
C++20正式引入了协程支持,通过新的关键字co_await、co_yield、co_return来辅助实现。这是一种基于编译器转换的机制,编译器将协程函数转换为状态机,使得函数可以在某个点暂停并稍后恢复。理解C++20协程需要一定的学习曲线,因为它涉及到了一些底层概念(如承诺类型 promise_type、挂起点suspend_always、suspend_never等)。这里我们提供一个简化的C++20协程示例,来展示其暂停/恢复的核心思想。
示例:C++20协程模拟简单的序列生成器
假设我们想创建一个函数,每次调用它时,它返回序列中的下一个数字,但这个函数可以在内部暂停,而不必每次都从开头开始。这正是co_yield的典型应用场景。
#include <vector>
#include <coroutine> //C++20 协程头文件
#include <optional>
#include <vector>
//1.协程的承诺类型(promise_type)
//决定协程如何暂停、恢复、返回值等
template <typename T>
struct Generator{
struct promise_type{
T value_; //存储co_yield的值
std::coroutine_hand<promise_type> parent_handle; //存储调用者的句柄
//当协程首次进入时,在这里生成协程句柄,并将其返回给调用者
Generator get_return_object(){
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
//协程开始执行时挂起(通常设置为 suspend_always才能暂停)
std::suspend_always initial_suspend(){return {};}
//协程结束时挂起(可以用来清理资源,或者实现final_suspend机制)
std::suspend_always final_suspend() noexcept {return {};}
//处理co_yield T; 语句,将值存储起来,并挂起协程
std::suspend_always yield_value(T value){
value_ = value;
return {}; //挂起协程
}
//处理co_return;或co_return something;语句
void return_void(){} //我们的生成器不直接返回,通过co_yield返回
//异常处理
void unhandled_exception(){
//通常这里会捕获异常,或者重新抛出
std::rethrow_exception(std::current_exception());
}
};
//协程句柄,用于控制协程的生命周期和执行
std::coroutine_handle<promise_type> handle_;
//构造函数
explicit Generator(std::coroutine_handle<promise_type> h) : handle_(h) {}
//析构函数,确保协程句柄被销毁
~Generator(){
if(handle_){
handle_.destroy();
}
}
//移动语义(禁止拷贝,协程句柄时独占资源)
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
Generator(Generator&& other) noexcept : handle_(other.handle_){
other.handle_ = nullptr;
}
Generator& operator=(Generator&& other) noexcept {
if (this != &other){
if (handle_) handle_.destroy();
handle_ = other.handle_;
other.handle_ = nullptr;
}
return *this;
}
//检查协程是否已经完成
bool done() const {return !handle_ || handle_.done();}
//恢复协程执行,并返回co_yield的值
std::optional<T> next(){
if (!handle_ || handle_.done()){
return std::nullopt; //协程已完成或未初始化
}
// 恢复协程执行,直到遇到co_yield 或 co_return
handle_.resume();
if(handle_.done()){
return std::nullopt; //协程执行完毕
}
return handle_.promise().value_; //返回co_yield的值
}
};
//2.一个使用 co_yield的协程函数
Generator<int> fibonacci_generator(int limit){
int a = 0 , b =1;
for(int i = 0; i < limit; ++i){
co_yield a; //暂停,并返回a
int next_fib = a + b;
a = b;
b = next_fib;
if(a > 1000){ //模拟一些额外条件下的退出
std::cout << "Fibonacci: Reached limit 1000, stop generating." << std::endl;
co_return; //结束协程
}
}
std::cout << "Fibonacci: All specified numbers generated." << std::endl;
}
int main(){
std::cout << "Generating Fibonacci sequence:" << std::endl;
Generator<int> fib_gen = fibonacci_generator(10); //创建协程对象
while(auto value = fib_gen.next()){ //每次调用next()恢复协程,直到co_yield
std::cout << *value << " ";
}
std::cout << "\nLong sequence generation finished." << std::endl;
return 0;
}
编译运行:
g++ -std=c++20 -fcoroutines example.cpp - o example
你需要支持C++20和fcoroutines的G++或Clang版本,输出大致如下:
Generating Fibonacci sequence:
0 1 1 2 3 5 8 13 21 34
Fibonacci: All specified numbers generated.
Sequence generation finished.
Generating another sequence with a higher limit:
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 Fibonacci: Reached limit 1000, stop generating.
Long sequence generation finished.
在这里例子中,fibonacci_generator函数看起来像是一个普通的函数,但它使用了co_yield。每此fib_gen.next()被调用时,协程从上次co_yield的地方恢复执行,直到遇到下一个co_yield或者co_return。这使得我们可以在需要时“拉取”下一个值,而不是一次性生成所有值。
协程的使用场景:
- 异步I/O操作:网络请求、文件读写等,当等待I/O时可以co_await,将控制权和交还给其他协程。
- 延迟计算/惰性求值:如上述的生成器。
- 状态机:通过co_yield和co_await可以清晰地表达状态转换。
- 并发任务流:在大量业务逻辑需要异步协同的场景。
总结表格:进程、线程、协程对比
特性/维度 | 进程 | 线程 | 协程 |
---|---|---|---|
定义 | 操作系统资源分配的基本单位 | CPU调度和执行的基本单位,是进程内的执行流 | 用户态的、协作式调度(非抢占式)的轻量级执行单元 |
资源拥有 | 独立内存空间、文件、设备等所有系统资源 | 共享进程的内存空间和大部分系统资源,有独立的栈和寄存器 | 共享线程的内存空间,但有独立的栈(或少量堆空间) |
切换 | 上下文切换设计OS,开销最大,需要内核态/用户态切换 | 上下文切换涉及OS,开销较小,仍可能涉及内核态/用户态切换 | 用户态切换,无需OS参与,开销极小 |
调度 | OS调度器抢占式调度 | OS调度器抢占式调度 | 用户程序或协程调度器协作式调度(必须主动交出控制权) |
通信 | IPC(管道、共享内存、消息队列等),复杂且高开销 | 直接访问共享内存,通过同步机制(锁、条件变量等)同步 | 直接访问共享内存,通常不需要锁(因为是协作式,一个协程运行时另一个不会跑) |
并发数 | 数十到数百个 | 数百到数千个 | 数万到数十万个(理论上更多) |
隔离性 | 强(一个进程崩溃不影响其他) | 弱(一个线程崩溃可能导致整个进程崩溃) | 极弱(一个协程的错误可能影响到同线程内的其他协程) |
C++支持 | frok/exec(系统调用),<process>等(非标准) | <thread>(C++11+) | <coroutine>(C++20+) |
典型应用 | 独立的应用程序(浏览器、IDE等) | UI响应、后台任务、并发计算、服务器请求处理 | 高并发网络服务、异步I/O、生成器、NPM包管理器命令等 |
总结
通过这篇博客,我们由浅入深地探讨了进程、线程和协程这三个重要的并发概念。
- 进程是程序执行的独立实例,拥有独立的资源。
- 线程是进程内的执行单元,共享进程资源,通过操作系统实现抢占式并发。
- 协程是用户态的、协作式的并发单元,通过主动挂起/恢复实现超轻量级并发。
在C++中,std::thread
使得多线程编程成为标准,但需要注意数据同步的复杂性。C++20协程的引入,则为我们提供了更强大、更优雅的异步编程和高并发解决方案。
选择哪种并发模型,取决于你的应用场景和性能需求。单核CPU上,多线程/多进程是并发,而多核CPU上,它们是并行。协程则在单线程内实现多个任务的协作,在某些场景下可以发挥极致的性能和简洁性。
希望这篇博客能帮助你更好地理解这些概念,并在未来的C++编程中,能够游刃有余地驾驭它们,写出高效、健壮的并发程序!如果你有任何疑问,欢迎留言交流。