乐趣区

关于后端:重大变更二关于C26的十大猜想

你好,我是卢誉声。

上一讲,咱们理解了后续 C ++ 规范演进中,极有可能到来的个性或库变更——动态反射、异步工作框架、网络库和 Freestanding 库。

从将来规范的演进路线中,咱们其实能够一窥到底,不难发现,C++ 有着极为清晰的演进路线。

与此同时,C++ 规范还在致力补救不足之处,比方不足标准化的高性能计算能力、异步编程库、针对大数据处理的 Ranges 扩大等等。接下来,就让咱们持续漫游之旅,畅想将来 C ++ 规范演进可能迎来的另外六个变动吧。

高性能计算反对

对于 C ++26 来说,另一个重要指标是提供对高性能计算的反对。

不得不说,C++ 至今仍然能焕发活力,得益于人工智能等畛域对高性能计算的需要,C++ 简直是惟一一个同时正当兼顾性能和形象两个层面的编程语言。

然而,C++ 不足对高性能计算的规范反对,无论是根底的多维向量、对 CPU SIMD 的指令封装,还是更高层的线性代数算法都是一片空白。

因而,从 C ++20 规范开始,一直有相干个性增加到规范库中,C++20 中的一维数组视图 span 和 C ++23 中的多维数组视图 mdspan,都是线性代数的根底类型向量的前置个性。同时,C++23 的多元索引操作符,也让多维向量拜访变得更加不便了。

在 C ++26 中,预计增加许多 要害的线性代数反对。

首先,可能会退出的就是多维数组 mdarray。多维数组 mdarray 的设计和 C ++23 中的多维数组视图 mdspan 是统一的,都能够用于示意多维数组。我在这里着重阐明它们之间的差别。

另一个 C ++26 可能反对的个性是 提供 SIMD 指令的封装。

在高性能计算中,SIMD(单指令多数据流)是常见的计算优化解决模式,各个 CPU 指令集都有相应指令提供反对,其指标是通过一条 CPU 指令计算多个多维向量。

目前 C ++ 编译器可能会在局部的计算优化中,主动应用这些指令。然而,在高性能计算中,咱们往往须要手动指定应用这些指令。因为规范库没有提供这些接口,导致咱们只能应用汇编指令或者相似 Intel 的 Intrinsics 接口来实现,常常须要做大量的平台适配工作。

为了解决这个问题,C++ 打算引入针对 SIMD 类型与指令的封装——这也是后续线性代数接口实现的计算根底。

最初,C++26 可能会基于 mdarray、mdspan 提供 线性代数函数库。

因为 BLAS(Basic Linear Algebra Subprograms)库根本曾经成为 C /C++ 中线性代数的事实标准,CBLAS、ATLAS 和 OpenBLAS 等基于 BLAS 接口的库,也成了大部分科学计算库的基础设施(比方 Python 驰名的 NumPy 就是基于 BLAS 开发的)。

因而,C++ 的线性代数接口也就以 BLAS 接口为根底设计。C++ 的 BLAS 接口会应用 mdspan 形容向量数据类型,可能应用 SIMD 实现局部计算减速。至于与 BLAS 同时设计的 LAPACK,尽管补充了更多线性代数性能,但因为各种起因可能要留待当前的 C ++ 规范决定何时实现。

Coroutines 扩大

C++20 中的 Coroutines 只提供了一套协定,具体实现须要开发者本人来实现。而 C ++23 里的 generator 提供了在生成器这种场景下的 coroutine 规范实现。

而在 C ++26 中,咱们终于能够看到面向一般协程工作的 coroutine 实现——std::lazy。

那么什么是 std::lazy 呢?咱们能够联合这段代码来了解。

#include <experimental/lazy>
#include <string>
 
struct Person {
    int id;
    std::string name;
};
 
std::lazy<Person> loadPerson(int id);
std::lazy<> savePerson(Person p);
 
std::lazy<void> modifyPerson() {Person person = co_await loadPerson(1);
    person.name = "学生名称";
    co_await savePerson(person);
}

通过代码能够看到,自定义的 coroutine 换成 lazy 之后,咱们不再须要本人实现协程的调度过程,间接通过 co_await 就能调用 loadPerson 和 savePerson,这可太棒了!

std::lazy 甚至能够容许咱们指定具体的 executor 调度协程,比方前面的代码。

co_await f().via(e);

这里的 e 就是一个 executor,这样咱们能够管制协程到底要通过哪个 executor 来唤醒执行,提供了更高的灵活性和更简略的接口。

说白了,std::lazy 就是一个通用协程工作封装,起了这么一个名字,是因为规范委员会的 SG1 偏向于将 task 这个名字保留给当前的其余概念应用,最初 LEGW(Library Evolution Work Group,即规范委员会负责库改良的工作组)就选用了 lazy 这个名字……

Ranges 扩大

到 C ++23 为止,Ranges 曾经相当欠缺了,惟一问题就是咱们无奈像 Python、JavaScript 和 Rust 一样,不便地将序列(包含 vector、map 和 set)打印到管制台上。

在 C ++26 中,可能会提出容器和 ranges 的格式化接口计划,也就是咱们能够写出这种代码:

#include <ranges>
#include <string>
#include <vector>
#include <cstdint>
#include <fmt/ranges.h>
 
int main() {
    std::vector<int32_t> v{1, 2, 3};
    fmt::print("{}\n", v);
 
    std::string s = "xyx";
    auto parts = s | std::views::split('x');
 
    fmt::print("{}\n", parts);
    fmt::print("<<{}>>\n", fmt::join(parts, "--"));
 
    return 0;
}

能够看出,相比原来的模式,这样的代码无论是调试代码,还是将容器 ranges 输入到文件中,编码都会更加不便。

Hive:Bucket Array 容器框架

现在,C++ 仍然是高性能交易与游戏编程的外围撑持,在这些畛域中,常常须要实现大量数据块的申请、开释与疾速检索,咱们常常采纳一种名为 Bucket array 的技术 来解决此类问题。

这种数据结构的原理是,将一个元素数组划分成块的数组,每个块蕴含多个理论的元素,每个元素有一个布尔标记位,示意这个元素是否被删除了。当遍历元素时,会跳过这些被标记删除的元素,只有一个块中的所有元素被标记删除后,这个块才会被真正开释。这么做,能够防止遍历时遇到全空的块。插入元素时,所有块都满了才会申请新的块。

这种形式大幅度缩小了小对象的内存申请(都以块为单位申请内容),防止过多零散的删除操作引起的移位拷贝,所以性能损耗大大降低。而且,还能确保很多相干元素间断存储在一个块中,保障很多场景下随机拜访的性能。能够说,这是一种在特定场景下衡量了插入、删除和检索性能的数据结构。

因为在 C ++ 的次要反对场景中,会大量用到这种数据结构,因而 C ++ 规范提出了相干提案,可能会在 C ++26 中正式纳入规范。

多线程无锁内存模型反对

基于多线程的高并发业务也是 C ++ 的重要利用场景,尤其在当初的高并发 Web 服务中有大量利用。

在这种场景中,性能的外围瓶颈可能并非长时间的计算,而是继续的高并发拜访。这种状况下,多个线程可能须要频繁地同时拜访雷同数据,天然就须要解决高并发场景下的数据竞争问题。

如果咱们采纳罕用的锁来解决问题,极其状况下会将本可并行的执行流变成串行执行流,重大拖慢并发性能。

为此,有针对不同场景的大量“无锁”内存模型,Hazard Pointer 就是一种面向“单写者、多读者”场景的无锁内存模型与根底数据结构。

你无妨联想一下,在很多 Web 服务中咱们可能都会采纳相似“读写拆散”的机制来解决数据的读写,Hazard Pointer 的原理也是一样的。这种数据结构的特点是,永远只有一个线程具备其所有权,并能够写入数据,其余线程只能拜访并读取其中的值,所以咱们用这种个性做无锁化的优化十分不便。

为了反对 Hazard Pointer,C++ 采纳了 RCU(read copy update)实现了一种面向多读少写个性的链式数据结构,也就是咱们熟知的“无锁队列”。

RCU 联合 Hazard Pointer 为咱们提供了一种“单写者、多读者”的多读少写的高并发无锁内存模型,能够为咱们解决特定的高并发业务提供基础设施反对。

定制点对象调整

最初咱们聊聊定制点对象,这是为了解决 ADL(Argument-Dependent Lookup,实参依赖查找)问题提出来的。

实质上,引入它是为了让用户能不便地应用命名空间中的符号。咱们还是联合代码示例了解。

#include <ranges>
#include <string>
#include <vector>
#include <cstdint>
#include <iostream>
 
namespace cp {
    struct Person {friend void swap(Person& lhs, Person& rhs);
        int32_t id;
        std::string name;
    };
 
    void swap(Person& lhs, Person& rhs) {
        std::cout << "Person swap" << std::endl;
 
        Person temp = lhs;
        lhs = rhs;
        rhs = temp;
    }
 
    std::ostream& operator<<(std::ostream& os, const Person& person) {
        os << person.id << " " << person.name << std::endl;
 
        return os;
    }
}
 
 
int main() {
    cp::Person p1{
        .id = 1,
        .name = "姓名 1"
    };
 
    cp::Person p2{
        .id = 2,
        .name = "姓名 2"
    };
 
    std::cout << p1 << std::endl;
    // 通过 p2 找到 cp::operator<<
    operator<<(std::cout, p2) << std::endl;
 
    // 通过 p1,p2 找到 cp::swap
    swap(p1, p2);
 
    cp::operator<<(std::cout, p1) << std::endl;
 
    return 0;
}

能够看到,在代码第 8 行定义了 Person 类,第 14 行定义了 swap 函数,第 22 行定义了 << 的操作符重载。这些符号都被定义在命名空间 cp 中。

当初来看一下代码 41 和 43 行,咱们都晓得其实 C ++ 的操作符重载只是一个语法糖,std::cout << p1 的实质相当于 operator<<(std::cout, p1),所以 43 行的用法没有什么问题。

然而,代码 46 行就很奇怪了,咱们明显没有 using cp::swap,也没有 using namespace cp,为什么这里 C ++ 能找到 cp::swap 这个函数呢?

命名空间是为了解决名称隔离的问题引入的,但也让咱们援用符号变得比拟麻烦。比如说,咱们为了防止命名空间净化,基本上不会应用 using namespace,而是在每次援用符号时,都加上残缺的命名空间(比方应用 cp::swap)。

不过有些问题,咱们必须要交给编译器来解决。比如说,代码中的 operator<< 重载。既然 std::cout << p1 只是 operator<<(std::cout, p1)的语法糖,而 operator<<(std::ostream&, const Person&)是在 cp 这个命名空间中定义的函数,那么编译器要怎么找到 cp:: operator<< 这个函数呢?

为此,C++ 提出了 ADL。简略来说,就是调用函数时,只有有一个参数的类型属于函数所在的命名空间,那么调用的时候就不必加命名空间前缀(当然理论状况必定简单得多 ……)。

这样一来,编译器就能够通过 operator<<(std::cout, p1)将 std::operator<< 或者 cp::operator<< 作为候选符号,最初匹配满足调用条件的版本。

这个能力,最终在 C ++ 规范中从操作符重载被扩大到了一般函数,就变成了咱们当初所知的 ADL,这也解释了为什么代码 46 行可能编译胜利。

不过,这种个性在后续过程中被“非法”使用到了 C ++ 的其余局部,来实现用户自定义的扩大(想想模板元编程是怎么呈现的)。比方 C ++20 Ranges 中的 begin、end 等函数,都须要相似的个性。但这种场景下应用 ADL 并不在语言设计的预期中,天然会存在各种各样的问题。

因而,C++ 在规范中一直对它修修补补。CPO(定制点对象)就是其中一种。你能够这样了解,C++ 给咱们提供了一个以特定对象作为扩大点,让开发者能够针对本人的类型定制对应的行为,这就是为什么叫做定制点。

不过 CPO 仍然存在很多问题,在筹备推出的 execution 中,就有很多符号须要此类反对以供第三方定制扩大。因而,C++ 原本筹备引入 tag_invoke 来缓解这个问题,但这只不过又是一个给 ADL 收烂摊子的计划。

所幸,因为 execution 个性提早,规范委员会才有机会在 tag_invoke 这种治标不治本的计划进入规范之前,在 C ++26 之后(大概率在 C ++26),彻底消除在扩大点中应用 ADL 带来的问题,最终齐全对立扩大点的技术计划。

总结

人工智能技术倒退和高性能计算畛域,既须要靠近硬件层,又须要提供下层接口。惟一一个兼顾性能和形象两方面的编程语言,简直只有 C ++。

不过,尽管 C ++ 具备如此素质,但规范库统一不足对这些畛域所依赖的底层技术的反对。因而工程师们被迫引入大量第三方库,但集成过程里的问题(比方不同库之间的适配问题、体系结构、操作系统的兼容性问题)也是层出不穷,令人头大。

现在,从 C ++11 再到 C ++20 以及后续演进规范,咱们能分明看到 C ++ 的扭转以及演进路线,C++ 规范将提供更多“标准化的解决方案”,这些倒退无望大幅升高开发者编写代码的复杂度。

由此,咱们有理由揣测,之后 C ++ 在机器学习和高性能计算等重要应用领域中,会大放异彩,越战越勇。

咱们在课程中曾本人实现了简直所有的 C ++ Coroutines 接口约定。然而能够看到,在后续的规范演进中,coroutine 库行将到来,届时,咱们就能够通过 std::lazy 来彻底简化异步编程的工作。这可太让人期待了。

最初,Ranges 针对 format 的扩大也让代码调试与容器输入变得更加不便。

这些新个性的提出和对现有规范中的个性的补足,将会把 C ++ 推向一个全新的高度。C++26 或其后续规范,将是一个极为令人激动的重大变更。让咱们一起期待它们的到来,以及编译器对新规范的反对吧。

课后思考

最初给你留两道思考题。

1. 在人工智能畛域,C++ 无处不在。那么,你能说出哪些技术或工具在应用 C ++ 呢?

2. 我在这一讲中提到了名为 Bucket array 的技术,用于解决内存碎片的问题。那么,你在日常工作中,是如何防止内存碎片导致的性能降落问题呢?

欢送说出你的实际,与大家一起分享。咱们一起探讨,下一讲见。

文章起源:极客工夫《古代 C++20 实战高手课》

退出移动版