C++14标准库实用工具下

1:开篇

本篇是 C++14 系列的收尾篇,将讲解 C++14 在并发编程字面量系统 两个领域的重要改进。这两个特性看似关联不大,却分别代表了 C++14 对高性能多线程编程代码可读性的终极优化。

std::shared_timed_mutex 填补了 C++11 标准库没有读写锁的空白,而用户定义字面量则让 C++ 的语法更加贴近自然语言。掌握这两个特性后,你将完整掌握现代 C++(C++11/C++14)的全部核心能力。

2:std::shared_timed_mutex与读写锁

1:基础语法

C++11 只提供了最基础的互斥锁 std::mutex,它采用独占访问模式 ------ 任何时刻只能有一个线程持有锁。对于读多写少的场景(如缓存、配置中心),独占锁会严重降低并发性能。

C++14 引入了 std::shared_timed_mutex,它支持两种访问模式:

  • 共享模式(读锁):多个线程可以同时持有,用于只读操作
  • 独占模式(写锁):任何时刻只能有一个线程持有,用于修改操作
cpp 复制代码
#include <iostream>
#include <shared_mutex>
#include <mutex>
#include <thread>
#include <vector>
#include <chrono>
#include <syncstream> // C++20 同步输出,保证多线程输出不乱序

// 定义别名,方便后续切换互斥锁类型
using MutexType = std::shared_timed_mutex;

class ThreadSafeCounter {
private:
    // mutable:允许在const成员函数中修改mutex
    mutable MutexType mutex_;
    unsigned int value_ = 0;

public:
    ThreadSafeCounter() = default;

    // 读操作:使用共享锁(shared_lock)
    unsigned int get() const {
        std::shared_lock<MutexType> lock(mutex_);
        return value_;
    }

    // 写操作:使用独占锁(unique_lock)
    void increment() {
        std::unique_lock<MutexType> lock(mutex_);
        ++value_;
    }

    // 尝试获取独占锁(非阻塞)
    bool try_increment() {
        std::unique_lock<MutexType> lock(mutex_, std::try_to_lock);
        if (lock.owns_lock()) {
            ++value_;
            return true;
        }
        return false;
    }

    // 一段时间内尝试获取独占锁(带超时)
    bool try_increment_for(int milliseconds) {
        std::unique_lock<MutexType> lock(
            mutex_, std::chrono::milliseconds(milliseconds));
        return lock.owns_lock() ? (++value_, true) : false;
    }
};

int main() {
    ThreadSafeCounter counter;
    const int N = 10;

    // 写线程:每100ms递增一次
    auto writer = [&counter]() {
        for (int i = 0; i < N/2; ++i) {
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
            counter.increment();
            std::osyncstream(std::cout) 
                << "写线程 " << std::this_thread::get_id() 
                << ":计数器值 = " << counter.get() << "\n";
        }
    };

    // 读线程:每50ms读取一次
    auto reader = [&counter]() {
        for (int i = 0; i < N; ++i) {
            std::this_thread::sleep_for(std::chrono::milliseconds(50));
            std::osyncstream(std::cout) 
                << "读线程 " << std::this_thread::get_id() 
                << ":读取到值 = " << counter.get() << "\n";
        }
    };

    // 启动线程:1个写线程 + 3个读线程
    std::vector<std::thread> threads;
    threads.emplace_back(writer);
    for (int i = 0; i < 3; ++i) {
        threads.emplace_back(reader);
    }

    // 等待所有线程结束
    for (auto& t : threads) {
        t.join();
    }

    std::cout << "最终计数器值:" << counter.get() << "\n";
    return 0;
}

2:读写锁原理和最佳实践

1:读写锁工作机制

读写锁的核心思想是 **"读写分离"**:

  • 当没有线程持有写锁时,任意数量的线程都可以同时持有读锁
  • 只要有一个线程持有写锁,其他所有线程(读和写)都必须等待
  • 当有线程等待写锁时,新的读锁请求会被阻塞,避免写饥饿

注意:不同实现的读写锁调度策略不同。大多数实现(如 GCC 的 libstdc++)采用写优先策略,即写锁的优先级高于读锁,防止写线程长时间等待。

2:shared_timed_mutex和std::mutex对比

读多写少的场景下,读写锁的性能优势非常明显:

场景 std::mutex QPS std::shared_timed_mutex QPS 性能提升
100% 读 ~100 万 ~1000 万 10 倍
99% 读 1% 写 ~50 万 ~500 万 10 倍
90% 读 10% 写 ~10 万 ~50 万 5 倍
50% 读 50% 写 ~5 万 ~3 万 反而下降

结论 :读写锁只适用于读操作远多于写操作的场景。如果读写比例接近 1:1,读写锁的性能会比普通互斥锁更差,因为它需要维护更复杂的状态。

3:std::shared_timed_mutex和C++17的std::shared_mutex

C++17 引入了更轻量的 std::shared_mutex,它和 shared_timed_mutex 的唯一区别是不支持超时锁定接口

特性 std::shared_timed_mutex(C++14) std::shared_mutex(C++17)
共享锁 lock_shared()/unlock_shared()
独占锁 lock()/unlock()
非阻塞尝试锁 try_lock()/try_lock_shared()
超时尝试锁 try_lock_for()/try_lock_shared_for()
性能 较高 更高(无超时逻辑开销)

选择原则

  • 如果不需要超时功能,且编译器支持 C++17,优先使用std::shared_mutex
  • 如果需要超时控制,或者只能使用 C++14,使用std::shared_timed_mutex
4:RAII锁的正确使用

C++14 同时提供了 std::shared_lock 用于管理共享锁,和 std::unique_lock 配合使用:

  • 读操作:必须使用 std::shared_lock<MutexType>
  • 写操作:必须使用 std::unique_lock<MutexType>

错误用法:不要用 std::lock_guard 管理读写锁,它不区分共享和独占模式。

3:常见陷阱和注意事项

1:锁升级导致死锁
cpp 复制代码
// 致命错误:读锁升级为写锁会导致死锁
void bad_upgrade() {
    std::shared_lock lock(mutex_); // 持有读锁
    // 此时其他线程也持有读锁
    std::unique_lock write_lock(mutex_); // 永远无法获取写锁,死锁!
}

解决方法:先释放读锁,再获取写锁;或者直接使用写锁。

2:写饥饿问题

虽然大多数实现采用写优先策略,但如果读线程非常密集,写线程仍然可能长时间等待。 解决方法:限制同时持有读锁的线程数量,或者使用带超时的写锁。

3:不要在持有锁期间执行耗时操作

无论是读锁还是写锁,持有锁的时间越长,并发性能越低。尽量将耗时操作放在锁外执行。

4:互斥锁的可重入性

std::shared_timed_mutexstd::mutex 一样,都是不可重入的。同一个线程不能重复获取同一个锁,否则会导致未定义行为。

3:用户定义字面量

1:基础语法

字面量后缀是附加在字面量后面的标识符,用于明确指定字面量的类型。C++98 已经支持一些内置后缀(如ulf),C++11 允许用户自定义后缀,C++14 则在标准库中提供了大量实用的标准后缀。

cpp 复制代码
#include <iostream>
#include <string>
#include <string_view>
#include <chrono>

// 自定义字面量后缀:必须以下划线开头
// 1. 字符串字面量后缀
std::string operator "" _s(const char* str, size_t len) {
    return std::string(str, len);
}

std::string_view operator "" _sv(const char* str, size_t len) {
    return std::string_view(str, len);
}

// 2. 浮点数字面量后缀:公里转米
constexpr long double operator "" _km(unsigned long long x) {
    return x * 1000.0L;
}

// 3. 浮点数字面量后缀:弧度转角度
constexpr long double operator "" _pi(long double x) {
    return x * 3.14159265358979323846L;
}

int main() {
    // 使用自定义后缀
    auto str = "hello"_s; // std::string
    auto sv = "world"_sv; // std::string_view
    auto distance = 5_km; // 5000.0L
    auto angle = 2.0_pi;  // 6.283185307...

    // C++14 标准库后缀(需要引入命名空间)
    using namespace std::literals;
    
    // 字符串字面量
    auto std_str = "hello"s;   // std::string
    auto std_sv = "world"sv;   // std::string_view(C++17)
    
    // 时间字面量
    auto hours = 24h;          // std::chrono::hours
    auto minutes = 30min;      // std::chrono::minutes
    auto seconds = 10s;        // std::chrono::seconds
    auto ms = 100ms;           // std::chrono::milliseconds

    // 使用时间字面量
    std::this_thread::sleep_for(500ms); // 比 std::chrono::milliseconds(500) 简洁得多

    return 0;
}

2:字面量系统的设计和应用

1:底层operator""重载

用户定义字面量的本质是重载特殊的运算符 operator"",不同类型的字面量对应不同的参数形式:

字面量类型 运算符签名 示例
整型 ReturnType operator "" _suffix(unsigned long long); 123_suffix
浮点型 ReturnType operator "" _suffix(long double); 3.14_suffix
字符 ReturnType operator "" _suffix(char); 'a'_suffix
字符串 ReturnType operator "" _suffix(const char*, size_t); "abc"_suffix

强制规则:所有用户自定义后缀必须以下划线_开头 。不以_开头的后缀保留给标准库使用(如shms)。

2:标准库提供的常用字面量

C++14 及后续版本在 std::literals 命名空间中提供了丰富的标准字面量:

后缀 类型 头文件 示例
s std::string <string> "hello"s
sv std::string_view <string_view>(C++17) "hello"sv
h std::chrono::hours <chrono> 24h
min std::chrono::minutes <chrono> 30min
s std::chrono::seconds <chrono> 10s
ms std::chrono::milliseconds <chrono> 100ms
us std::chrono::microseconds <chrono> 100us
ns std::chrono::nanoseconds <chrono> 100ns
i std::complex<double> <complex> 2.0+3.0i

注意 :标准库字面量不会自动导入,必须显式使用 using namespace std::literals; 或者更具体的命名空间(如 std::chrono_literals)。

3:编译期字面量

operator"" 声明为 constexpr,可以实现编译期字面量计算,所有计算都在编译时完成,运行时无开销:

cpp 复制代码
// 编译期字节单位转换
constexpr unsigned long long operator "" _KB(unsigned long long x) {
    return x * 1024;
}

constexpr unsigned long long operator "" _MB(unsigned long long x) {
    return x * 1024_KB;
}

constexpr unsigned long long operator "" _GB(unsigned long long x) {
    return x * 1024_MB;
}

int main() {
    // 编译期计算:1GB = 1073741824字节
    constexpr auto buffer_size = 1_GB;
    static_assert(buffer_size == 1073741824ULL);
    return 0;
}
4:应用
  • 用于单位转换:这是自定义字面量最经典的应用场景,如长度单位(米、千米)、数据单位(KB、MB)、时间单位等

  • 用于类型安全 :避免不同单位之间的隐式转换错误

    cpp 复制代码
    struct Meter { long double value; };
    struct Kilometer { long double value; };
    
    constexpr Meter operator "" _m(long double x) { return {x}; }
    constexpr Kilometer operator "" _km(long double x) { return {x}; }
    
    // 错误:不能直接相加,必须显式转换
    // auto total = 1_m + 1_km;
  • 用于 DSL(领域特定语言):可以用字面量构建简洁的领域特定语言,如正则表达式、SQL 查询等

3:常见陷阱和注意事项
  • 命名空间问题:标准库字面量必须显式导入命名空间,否则无法使用
  • 后缀冲突:不同库可能定义相同的后缀,导致冲突。解决方法是使用命名空间隔离
  • 字符串字面量的长度参数 :字符串字面量的 operator"" 必须接受两个参数(字符串指针和长度),不能只接受一个参数
  • 不要过度使用:自定义字面量可以提高代码可读性,但过度使用会导致代码难以理解。只在能显著提升可读性的场景下使用。

4:本章总结

特性 解决的问题 核心优势 适用场景
std::shared_timed_mutex C++11 没有读写锁的空白 读多写少场景下的高并发 缓存、配置中心、只读数据结构
用户定义字面量 字面量类型不明确、可读性差 代码更接近自然语言、类型安全 单位转换、DSL、标准库类型快速创建
  • 读多写少场景必须使用读写锁:不要用普通互斥锁替代,否则会浪费大量并发性能
  • 永远使用 RAII 锁管理读写锁 :不要手动调用lock()/unlock(),避免异常导致的死锁
  • 优先使用标准库字面量sms等标准后缀已经成为现代 C++ 的通用写法
  • 自定义字面量只用于通用场景:不要为每个项目都定义大量自定义后缀
  • 将 C++14 作为项目的最低标准:目前所有主流编译器都完全支持 C++14,没有理由再使用 C++11 或更早的版本

5:C++14全部特性总结

C++14 作为 C++11 的补丁版本,虽然没有引入颠覆性的新特性,但它填补了 C++11 的大量空白,让现代 C++ 真正变得可用、易用、高效

类别 核心特性 解决的问题
语法改进 变量模板 消除类模板静态常量的冗余
泛型 Lambda 简化局部泛型函数的写法
函数返回类型推导 简化函数返回类型声明
二进制字面量 提升位操作的可读性
数字分隔符 让长数字更易读
聚合类默认初始化 避免聚合类的未初始化错误
标准库扩展 std::exchange 简化 "替换并返回旧值" 的操作
std::make_unique 解决直接使用 new 的异常安全问题
std::integer_sequence 提供模板元编程的基础工具
std::quoted 简化带引号字符串的 I/O 操作
std::shared_timed_mutex 提供读写锁支持
标准库字面量 提升代码可读性和类型安全

C++14 彻底巩固了 C++11 开创的现代 C++ 编程范式:

  1. 从手动管理资源到智能指针std::make_unique 的加入让 std::unique_ptr 成为默认的资源管理方式
  2. 从函数对象到 Lambda:泛型 Lambda 和初始化捕获让 Lambda 成为现代 C++ 中最常用的函数对象
  3. 从模板冗余到简洁表达:变量模板、返回类型推导大幅简化了模板代码的书写
  4. 从裸指针到安全抽象:标准库提供了越来越多的安全抽象,让开发者无需直接操作指针