【C++面试】线程并发与异步:区别、实现原理及使用场景

一、并发、并行和异步有什么区别

1. 并发(Concurrency)

并发指的是:

多个任务在同一时间段内交替推进。

假设 CPU 只有一个核心,需要处理三个任务:

复制代码
时间 ─────────────────────────→

CPU核心:

任务A → 任务B → 任务C → 任务A → 任务B → 任务C

虽然 CPU 同一时刻只能执行一个任务,但由于切换速度很快,看起来像多个任务同时运行。

这就是并发。

例如:

复制代码
void taskA() { /* 处理任务A */ }
void taskB() { /* 处理任务B */ }

我们可以创建两个线程:

复制代码
std::thread t1(taskA);     // 创建线程1
std::thread t2(taskB);     // 创建线程2

t1.join();                 // 等待线程1结束
t2.join();                 // 等待线程2结束

这两个线程可以并发执行。

但注意:

并发不代表两个线程一定在同一时刻运行。

2. 并行(Parallelism)

并行指的是:

多个任务在同一时刻真正执行。

例如有两个 CPU 核心:

复制代码
时间 ─────────────────────→

CPU核心1:任务A ───────────→

CPU核心2:任务B ───────────→

此时:

复制代码
任务A和任务B
       ↓
真正同时执行

因此:

  • 并发:强调多个任务能够交替推进。

  • 并行:强调多个任务真正同时执行。

并行通常需要多个可用的 CPU 执行核心,而并发不一定需要。

3. 异步(Asynchronous)

异步关注的不是有多少线程,而是:

发起一个操作后,不必立即等待它完成,可以先执行其他工作。

例如同步执行:

复制代码
downloadFile();             // 等待下载完成
processFile();              // 下载结束后才能执行

执行顺序:

复制代码
开始下载
   ↓
一直等待
   ↓
下载完成
   ↓
处理文件

如果采用异步方式:

复制代码
发起下载
   ↓
下载任务继续进行
   ↓
主流程先执行其他工作
   ↓
下载完成
   ↓
获取结果并处理

核心区别:

复制代码
同步
↓
当前流程需要等待结果才能继续

异步
↓
当前流程可以先做别的事情
之后再获取结果

注意,异步不一定需要创建新线程。

例如 Linux 的 epoll 配合非阻塞 socket,也可以在单线程事件循环中组织多个 I/O 操作,不需要为每个连接创建一个线程。

二、使用std::thread实现多线程并发

C++11 提供了:

复制代码
#include <thread>

可以直接创建线程。

例如:

复制代码
#include <iostream>
#include <thread>

void task(int id) { std::cout << "任务:" << id << '\n'; }  // 线程任务函数

int main() {
    std::thread t1(task, 1);    // 创建线程1,执行task(1)
    std::thread t2(task, 2);    // 创建线程2,执行task(2)

    t1.join();                  // 等待线程1执行结束
    t2.join();                  // 等待线程2执行结束
    return 0;
}

执行流程:

复制代码
               主线程
                  │
          ┌───────┴───────┐
          ↓               ↓
       线程t1           线程t2
          ↓               ↓
       task(1)          task(2)
          │               │
          └───────┬───────┘
                  ↓
              主线程结束

这里有两个重要函数。

join():等待线程结束

复制代码
t1.join();                     // 当前线程等待t1执行完成

如果 t1 还没执行完:

复制代码
调用join()
    ↓
当前线程阻塞等待
    ↓
t1执行结束
    ↓
当前线程继续

detach():分离线程

复制代码
t1.detach();                   // 线程独立运行,不再由t1对象管理等待

分离以后,不能再对 t1 调用 join()。

需要特别注意:detach() 并不代表线程绝对安全,它仍然可能访问已经销毁的对象,因此实际开发中应谨慎使用。

还有一个常见面试陷阱:

复制代码
std::thread t1(task, 1);
t1.join();

std::thread t2(task, 2);
t2.join();

虽然创建了两个线程,但它们是依次执行的。

因为:

复制代码
t1启动
   ↓
等待t1完成
   ↓
t2启动
   ↓
等待t2完成

所以想让两个任务有机会并发运行,应该先启动两个线程,再等待它们完成。

三、使用std::async实现异步任务

C++11 还提供了:

复制代码
#include <future>

其中两个核心工具是:

复制代码
std::async      // 启动异步任务
std::future     // 获取任务结果

例如:

复制代码
#include <iostream>
#include <future>
#include <thread>
#include <chrono>

int calculate() {
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟耗时操作
    return 100;                                            // 返回计算结果
}

int main() {
    auto result = std::async(std::launch::async, calculate); // 启动异步任务

    std::cout << "主线程继续执行其他工作\n";                 // 不必立即等待
    std::cout << "计算结果:" << result.get() << '\n';        // 获取结果,未完成则阻塞

    return 0;
}

执行流程:

复制代码
主线程
   │
   ├── std::async()
   │       ↓
   │    异步执行calculate()
   │       ↓
   │    计算完成,保存结果
   │
   ├── 主线程继续工作
   │
   └── future.get()
           ↓
        获取计算结果

这里最重要的是:

复制代码
std::async(std::launch::async, calculate);

其中:

复制代码
std::launch::async

明确要求任务异步执行,而不是等到获取结果时才开始执行。

如果不指定启动策略:

复制代码
auto result = std::async(calculate);

实现可以选择立即异步执行,也可以选择延迟执行。

另外:

复制代码
result.get();

并不是永远不阻塞。

如果异步任务还没有完成:

复制代码
future.get()
     ↓
等待结果
     ↓
任务完成
     ↓
返回结果

所以异步真正的优势是:

在启动任务和获取结果之间,主线程可以执行其他工作。

例如:

复制代码
auto result = std::async(std::launch::async, calculate); // 启动异步计算

doOtherWork();                                            // 主线程处理其他任务

int value = result.get();                                 // 需要结果时再等待

还有一个重要区别:

std::thread 通常需要自己处理线程的生命周期和任务结果,而 std::async 可以通过 std::future 获取返回值以及任务中的异常。

需要注意的是,std::async(std::launch::async, ...) 返回的 future 在特定销毁场景下也可能等待任务完成。因此,不能简单认为只要使用 std::async,当前线程就绝对不会阻塞。

四、线程并发与异步有哪些关系

这是面试中最容易混淆的部分。

其实:

多线程是一种执行机制,异步是一种任务组织方式。

两者不是互相对立的概念。

例如:

复制代码
多线程 + 同步等待

std::thread t(task);    // 启动工作线程
t.join();               // 立即等待结果

这里使用了多线程,但当前线程立即进入同步等待。

再比如:

复制代码
多线程 + 异步执行

std::thread t(task);    // 启动任务
doOtherWork();          // 主线程继续工作
t.join();               // 最后再等待

主线程没有立即等待,而是先执行了其他操作。

还有:

复制代码
单线程 + 异步I/O

例如 Linux 网络服务器:

复制代码
           epoll事件循环
                 │
        ┌────────┼────────┐
        ↓        ↓        ↓
      fd1       fd2      fd3
        │        │        │
        └────────┼────────┘
                 ↓
          非阻塞读写I/O

整个程序即使只有一个事件线程,也可以管理大量连接。

因为它不是:

复制代码
读取fd1
   ↓
阻塞等待数据
   ↓
处理fd2

而是:

复制代码
等待就绪事件
   ↓
fd1可读 → 处理fd1
fd2可读 → 处理fd2
fd3可写 → 处理fd3

所以:

复制代码
并发不一定需要多核
异步不一定需要多线程
多线程也不代表一定异步

多线程并发还需要注意数据竞争

例如:

复制代码
int count = 0;                             // 共享变量

void add() {
    for (int i = 0; i < 10000; ++i) ++count; // 多线程修改,存在数据竞争
}

如果两个线程同时执行:

复制代码
std::thread t1(add);                       // 线程1修改count
std::thread t2(add);                       // 线程2修改count

t1.join();                                 // 等待线程1
t2.join();                                 // 等待线程2

那么:

复制代码
++count;

不是一个普通意义上不可分割的原子操作。

可能出现:

复制代码
线程1读取count = 10
线程2读取count = 10

线程1计算11
线程2计算11

线程1写回11
线程2写回11

本来应该增加两次:

复制代码
10 → 12

结果却丢失了一次更新。

在 C++ 中,无同步地并发读写同一非原子变量属于数据竞争,会导致未定义行为。

解决办法之一:

复制代码
#include <atomic>

std::atomic<int> count{0};                 // 原子变量

void add() {
    for (int i = 0; i < 10000; ++i) ++count; // 原子递增
}

如果是多个变量组成的复杂共享状态,则通常需要:

复制代码
std::mutex
std::lock_guard

来保护临界区。

因此并发开发中除了追求性能,还必须关注:

复制代码
线程安全
锁竞争
死锁
数据竞争
任务调度

五、实际开发中应该如何选择

常见方法对比:

技术 核心特点 适用场景
std::thread 显式创建和管理线程 独立线程任务、长期运行的工作线程
std::async 启动任务并通过 future 获取结果 简单后台计算、有返回值的异步任务
线程池 复用工作线程 大量需要调度的中小型任务
epoll I/O 多路复用 Linux 高并发网络服务器
Qt 信号槽与工作线程 跨线程通知和事件驱动 Qt 客户端、GUI 后台任务

例如 Qt 客户端开发:

复制代码
GUI主线程
    ↓
点击"开始分析"
    ↓
工作线程解析大型日志文件
    ↓
分析完成
    ↓
发送信号
    ↓
GUI主线程更新界面

为什么要这样设计?

因为如果直接在 GUI 主线程执行:

复制代码
读取大型文件
复杂数据计算
数据库查询

就可能阻塞 GUI 事件循环,导致界面卡顿。

而使用工作线程:

复制代码
耗时任务在后台运行
         ↓
主线程继续响应鼠标、键盘
         ↓
处理结束后通知界面

用户体验就会更好。

不过需要注意,Qt 的 GUI 控件通常只能在 GUI 线程操作,因此工作线程不应该直接修改界面控件。


面试怎么回答

问题1:并发和并行有什么区别?

并发是多个任务在同一时间段内交替推进,可以通过线程调度实现;并行是多个任务在同一时刻真正运行,通常需要多个 CPU 核心。并发强调任务组织和推进,并行强调物理上的同时执行。

问题2:多线程和异步有什么区别?

多线程是通过多个线程执行任务,异步是任务启动后不必立即等待结果,可以继续处理其他事情。异步可以通过工作线程实现,也可以通过单线程事件循环和非阻塞 I/O 实现,因此异步不等于多线程。

问题3:std::thread 和 std::async 有什么区别?

std::thread 主要负责创建和管理线程,用户需要通过 join 或 detach 管理线程状态;std::async 更关注异步任务,可以通过 std::future 获取结果及异常。但要注意指定启动策略,否则任务可能延迟执行。

问题4:异步一定比同步快吗?

不一定。异步的主要价值是避免当前流程在等待期间无事可做,提高任务之间的重叠程度和程序响应能力。如果任务本身很短,异步调度开销反而可能超过收益。对于 CPU 密集型任务,还要考虑 CPU 核心数和线程竞争情况。

问题5:什么情况使用线程池?

如果系统中存在大量短任务,频繁创建和销毁线程会产生额外开销。可以提前创建一定数量的工作线程,通过任务队列分发任务,从而复用线程资源,并控制并发数量。

最后将整个知识体系总结为:

复制代码
                   任务执行方式
                        │
          ┌─────────────┼──────────────┐
          ↓             ↓              ↓
         并发           并行           异步
          ↓             ↓              ↓
     多任务交替推进  多任务同时执行   发起后不立即等待
          │             │              │
          ↓             ↓              ↓
     线程调度       多CPU核心       future/事件回调
                                      │
                            ┌─────────┴─────────┐
                            ↓                   ↓
                         多线程实现          单线程事件循环
                            ↓                   ↓
                        std::async             epoll

可以记住以下四句话:

  1. 并发不等于并行:并发关注多个任务的推进,并行关注真正同时执行。

  2. 异步不等于多线程:单线程事件循环也能实现异步处理。

  3. 多线程不一定更快:线程创建、上下文切换和锁竞争都会带来开销。

  4. 异步不代表永不阻塞 :调用 future.get()、join() 或其他等待操作时,当前线程仍然可能被阻塞。

对于 C++ 开发而言,真正重要的是根据任务特点选择合理的执行模型,而不是单纯追求更多线程或者更多异步接口。

0voice · GitHub

相关推荐
AIFQuant1 小时前
Python A股实时行情API接入:沪深股票数据与WebSocket推送实战
开发语言·python·websocket·金融·a股行情
2302_805771071 小时前
Day23:POM设计模式(封装)
java·开发语言·设计模式
十年Java程序媛1 小时前
Java String、StringBuilder、StringBuffer 底层原理|字符串拼接性能与线程安全
java·开发语言·spring boot·安全·面试
Cx330❀1 小时前
Qt 界面美化指南:从零掌握 QSS 核心机制与工程实践
开发语言·qt·ui·性能优化·图形渲染
可乐ea2 小时前
2000 个智能体把 Agent 重写成 Rust:编排流水线拆解
开发语言·后端·rust·多智能体协作·ai编程助手·智能体编排·rust重写
JMchen2 小时前
实战项目一——实现一个高性能图片滤镜App
android·c++·性能优化
我是人✓2 小时前
SpringMVC异常处理与拦截器实战揭秘
java·开发语言·后端
不是万能的豆学长2 小时前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go
码匠许师傅2 小时前
【C++三方组件】libpqxx:PostgreSQL 的 C++ 客户端
开发语言·c++·postgresql