简历书写
熟悉C++的封装、继承、多态,STL常用容器,熟悉C++11的Lambda表达式、智能指针等,熟悉C++20协程语法,具有良好的编码习惯与文档能力。
回答思路
这里是基本上就是要全会,考察的问题也很固定,stl这块可以定制自己的特色,C++(XX)特性、语法可以根据自己项目里用到的技术去补充。也可不写
封装继承多态问题有很多不一一解释
这里就写几个代表性问题,,基本上大家都会,根本就不需要看攻略。。。
封装
将具体的实现过程和数据封装成一个函数,通过接口进行访问,降低耦合性。
继承
继承是什么
继承其实就是在保持原有类特性的基础上进行扩展,增加功能。这样产生的新类叫派生类,而被继承的类称基类。其实继承主要就是类层次上的一个复用。
比如创建了一个动物的类,具有一些属性,但是呢具体到某类动物又有特有的属性,比如熊猫冬天爱睡觉,此时我们可以再创建一个熊猫 的类,继承动物类,复用它的属于,并且也增加了自己所特有的属性。
继承的三种方式
构造函数和析构函数的执行顺序
初始化列表概念,为什么用成员初始化列表会快一些
- 注意:对于const成员变量,必须通过初始化列表进行初始化
多重继承的问题,如何避免?
- 这里面包含菱形继承问题
多态
多态是什么
多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。在基类函数的前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
多态的实现方式
虚函数实现(虚函数表,虚函数表指针)
基类的指针是怎么绑定派生类的
其实,如果类包含了虚函数,那么在该类的对象内存中还会额外增加类型的信息,即type_info对象。
然后编辑器会在该虚函数表的开头插入一个指针,指向当前类对应的type_info对象。
当程序在运行阶段获取类型信息时,可以通过对象指针p找到虚函数表指针vfptr,再通过vfptr指针找到type_info对象的指针,进行得到类型信息。(多态指针类型的转换)
为什么构造函数不能是虚函数
为什么要把析构函数声明成虚函数
防止内存泄露
为什么不能把虚函数声明成inline(内联)
inline是在编译器将函数类替换到函数调用处,是静态编译的。而虚函数是动态调用的,在编辑器并不知道需要调用的是父类还是子类的虚函数,所以不能够inline声明展开,所以编辑器会忽略
为什么(静态)static成员函数不能为virtual
static成员不属于任何类对象或类实例,没有this指针
纯虚类概念,以及为什么要有纯虚类 (重点)
它没有实现体(即没有函数体),只有声明,被声明为纯虚函数的类被称为抽象类,不能直接实例化。抽象类中的纯虚函数需要在非抽象类中被实现,否则非抽象类也是抽象类。
如果在抽象类的派生类中没有重新说明纯虚函数,则该函数在派生类中仍然为纯虚类,而这个派生类仍然是一个抽象类
由于抽象类中至少包含有一个没有定义功能的纯虚函数,因此抽象类只能用作其他类的基类,不能建立抽象类对象
抽象类不能用作参数类型、函数返回类型或 显示转换的类型。但可以声明指向抽象类的指针变量,此指针可以指向它的派生类,进而实现多态
构造函数和析构函数中调用虚函数
其实就是,从语法上讲,调用完全没有问题;但是从效果上看,往往不能 达到需要的目的
比如,一个基类base构造函数和析构函数里调用Function虚函数,另一个继承这个基类的子类A的构造函数和析构函数里也调用这Function虚函数
然后在主程序中声明这个基类base指针,然后指向派生类A。这是会显示Base 类的构造函数中调用 Base 版本的虚函数,A
类的构造函数中调用 A 版本的虚函数;A 类的析构函数中调用 A 版本的虚函数,Base 类的析构函数中调用 Base 版本的虚函数
与预期不符,和普通函数一样
重载(overload )和重写(override)的区别还有隐藏是什么
函数重载原理(为什么C语言里面没有重载)
介绍下STL
回答这种比较大的问题,还是那个套路。能多答就多答,因为你答答的肯定是你会的。如果你简单说下名字就不说了,那人家面试官就自己问了,假如问道一个自己不会的就挂了。并且面试时间是固定的,你答的越多,问其他问题时间就少。
STL万能回答模板(先发制人版)
STL知道的挺多的,那我就来简单从前往后说下
- 首先说说array吧,这最基本的
我们一般用array容器来替代普通数组,因为array模板类中有好多已经写好的方法很方便编程
- 那接下来就是vector,动态扩容的数组么
vector是动态空间,随元素加入内部有一个机制会自动扩充空间——具体的他会变成两倍,当然这个是连续的内存空间放在堆里
那他的这个扩容方法就是:首先重新配置空间,元素移动,释放旧的内存空间。这里面一旦空间重新配置了则指向原来vector的所有迭代器都失效了,因为vector的地址改变了。所以迭代器是否失效就是看你地址从哪开始改变(比如说:在vector容器中间根据指定迭代器删除元素,也就是调用erase函数,此时因为当前位置会被后面的元素覆盖,所以该指定迭代器会失效)
所以这个他的特点就是删除等于覆盖,所以复制元素的机制就有点复杂,比如说你如果要是在提前没有 reserve 足够的空间,那么vector就会自动重新分配内存,他会将现有元素搬移到新的内存位置std::move (C++11新特性,右值引用,建议了解,在此不专门阐述)使用正确,它应该调用移动构造函数,但如果在某些实现中(例如容器大小调整时),编译器没有完全优化,也可能会进行拷贝构造。比如,如果容器在分配新内存后没有直接采用新内存而是继续使用旧内存,可能会触发一些额外的拷贝操作这样的。
然后说说函数使用方面(那肯定是最常用的访问、+、-):我们在操作vector的时候不确定的情况下还是用at而不是operator[];因为实际上你at函数就是调用的operator[]函数,只是多了一个检查是否越界的动作,而operator[]函数是直接跳转位置访问元素,所以速度是很快的,从时间复杂度看,是O(1)。还有放元素的时候push_back()会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自动销毁先前创建的这个元素);emplace_back()在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。clear()和erase(),实际上只是减少了size(),清除了数据,并不会减少capacity,所以内存空间没有减少。释放内存空间呢,正确的做法是swap()操作,其实就是让一个空vector与当前的交换,而这个空vector因为是一个临时变量,它在这行代码结束以后,会自动调用vector的析构函数进行释放,这样就相当于释放掉了
内存分配这里常遇到的一个问题是,你vector一个数据量特别大的元素就需要特别的注意:
1.使用 reserve() 预分配内存,避免频繁扩展。
2.按需加载数据(懒加载)。
3.使用指针或智能指针存储大对象,避免拷贝。
4.使用内存池、内存映射文件或分块存储来管理内存。
5.如果数据存储在外部文件中,可以考虑压缩存储或内存映射文件来提高效率。
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
int fd = open("large_data.dat", O_RDONLY);
size_t fileSize = /* 获取文件大小 */;
void* map = mmap(NULL, fileSize, PROT_READ, MAP_PRIVATE, fd, 0);
// 通过 map 访问文件内容
munmap(map, fileSize);
close(fd);
}
- 然后说下deque(双端队列),这个和数组联系挺大的就是——用数组管理map[*真正用来存储数据的各个连续空间]
为啥这么说呢?
1.deque 容器也擅长在序列尾部添加或删除元素(时间复杂度为O(1)),而不擅长在序列中间添加或删除元素。
2.deque 容器也可以根据需要修改自身的容量和大小。
但他肯定还是和vector不同,
deque 容器存储数据的空间是由一段一段等长的连续空间构成,各段空间之间并不一定是连续的,可以位于在内存的不同区域。
deque 容器用数组(数组名假设为 map)存储着各个连续空间的首地址。所以通过建立 map 数组,deque 容器申请的这些分段的连续空间就能实现“整体连续”的效果。
所以可知不同的是,
1.deque 还擅长在序列头部添加或删除元素,所耗费的时间复杂度也为常数阶O(1)。
2.并且更重要的一点是,deque 容器中存储元素并不能保证所有元素都存储到连续的内存空间中。
如果 map 数组满了怎么办?
很简单,再申请一块更大的连续空间供 map 数组使用,将原有数据(很多指针)拷贝到新的 map 数组中,然后释放旧的空间。
迭代器
迭代器内部包含四个指针:cur:指向当前正在遍历的元素;first:指向当前连续空间的首地址;last:指向当前连续空间的末尾地址;node:它是一个二级指针,用于指向map数组中存储的指向当前连续空间的指针
- 然后自然就想到了相反的list(又称双向链表容器)
STL list 容器,即该容器的底层是以双向链表的形式实现的。
这意味着,list 容器中的元素可以分散存储在内存空间里,而不是必须存储在一整块连续的内存空间中
- 之后自然可以延伸出stack因为他一般用list或者deque实现(不用vector的原因是该容量大小有限制,扩容耗时)
用队列实现栈的方法(来回倒腾)
使用两个队列实现栈:模拟入栈用一个队列1,模拟出栈的时候队列2用做备份,把队列1中除队列中的最后一个元素外的所有元素都备份到队列2中,然后弹出队列1的最后的元素,再把元素1从队列2导回队列1。
使用一个队列实现栈(弹出=插入+弹出)
模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素 )重新添加到队列尾部,此时再弹出元素的顺序就是栈的顺序
- 还有Queue(队列)底层一般用list或deque实现封闭头部即可,和上面的同理
queue也可以使用list作为底层容器,不具有遍历功能,没有迭代器
用栈实现队列(双栈法输入栈+输出栈)
用两个栈实现,一个是输入栈,另一个是输出栈。添加数据的时候,只要将数据放进输入栈即可。弹出数据的时候,如果输出栈为空,则把进栈数据全部导入输出栈,如果输出栈不为空,则直接从输出栈中弹出数据即可
- 最后就是set,map等
- set(集合,没有键值对,只有值)
底层红黑树,数值有序,不可重复,值不可修改
查询效率O(logn),增删效率O(logn)
- multiset
底层红黑树,数值有序,可重复,值不可修改,
查询效率O(logn),增删效率O(logn)
- unordered_set
底层哈希表,数值无序,不可重复,值不可修改,
查询效率O(1),增删效率O(1)
- map(有键值对)
底层红黑树,key有序,key不可重复,key不可修改(但是如果插入一个已经存在的键,它的值会被更新。不会出错),值可以重复
查询效率O(logn),增删效率O(logn)
当实现“向 map 容器中添加新键值对元素”的操作时,insert() 成员方法的执行效率更高;而在实现“更新 map
容器指定键值对的值”的操作时,operator[ ] 的效率更高
- multimap
底层红黑树,key是有序的,key可重复,key不可修改,值可以重复
查询效率O(logn),增删效率O(logn)
- unordered_map
底层哈希表,key是无序的,key不可重复,key不可修改,值可以重复
查询效率O(1),增删效率O(1)
用“链地址法”(又称“开链法”)(桶中存放一个链表)解决数据存储位置发生冲突的
- 哈希表
每个元素都有一个键(key),通过哈希函数将这个键映射为一个整数值(哈希值)。
这个哈希值通常是一个数组的索引,数组中的每个位置称为桶(bucket)。
哈希表中的元素按其键值存储在不同的桶中。
因为哈希值直接定位到桶的位置,所以查找、插入、删除的时间复杂度平均为 O(1)。
哈希表的一个重要问题是哈希冲突,即不同的键通过哈希函数计算得到相同的哈希值。在这种情况下,两个元素会被存储到同一个桶中。
链地址法(开链法)解决了哈希冲突的这个问题。
链地址法的核心思想是:当两个键的哈希值相同,直接将它们存储在同一个桶中,而不是覆盖前一个键值对。
也就是说每个桶中并不是存放一个单独的元素,而是存放一个链表,这个链表存储所有哈希值相同的键值对。哈希表的每个桶都是一个链表的头指针。
- unordered_map和哈希
假设有一个 unordered_map 存储键值对:
std::unordered_map<int, std::string> map;
创建桶:unordered_map 初始化时会创建一个数组,这个数组的大小是预先设定的,通常是一个质数。每个数组位置(即桶)是一个指向链表的指针。
//插入过程举例
map[2] = "apple"
hash(2) = 2
2 % 5 = 2
(2, "apple")
map[7] = "banana"
hash(7) = 7
7 % 5 = 2
[(2, "apple"), (7, "banana")]
map[12] = "cherry"
hash(12) = 12
% 5 = 2
[(2, "apple"), (7, "banana"), (12, "cherry")]
- unordered_map和哈希总结
哈希表:unordered_map 底层是一个哈希表,用哈希函数计算键的哈希值,并将元素存储到相应的桶中。
链地址法:当不同的键通过哈希函数映射到相同的桶时,使用链表来存储这些元素,解决哈希冲突问题。
桶和链表:每个桶是一个链表的头指针,所有哈希值相同的键值对存储在同一个桶对应的链表中。
效率:由于哈希函数可以直接定位到桶的位置,平均情况下查询、插入和删除操作的时间复杂度是 O(1),但最坏情况下,如果所有元素都落在同一个桶中,时间复杂度会退化为 O(n)。
通过这种方法,unordered_map 可以提供高效的查找、插入和删除操作。
算法
以下截图均为博主手搓精华版,可能晦涩难懂,需要有一定基础
四种强制转换
reinterpret_cast
const_cast
static_cast
dynamic_cast
C++11新特性
智能指针
lambda表达式
下面这些东西了解下即可
在实际工作中,下面的就function用的多一些。
如果非要说
这个时候说下,move,完美转发,function即可。
右值
移动语义
完美转发
function
无锁操作
模板
内存相关
内存分布
全局数据区?
堆区?
自由分配区?
内存泄露概念
没释放
常见的内存泄露
new 没 delete
局部分配未释放
delete void*
基类析构未定义为虚函数
内存泄漏工具的实现
hook(劫持)malloc 和 free 的底层函数,在里面插入东西
_libc_malloc 和 _libc_free 是底层的内存分配与释放函数,我们直接调用这两个函数来实现分配,
__builtin_return_address 函数,你可以获取调用 malloc 或 free 的位置(代码段地址),我们把他写到一个文件里
为每个分配的内存块创建一个文件,文件名使用内存地址作为文件名,文件内容可以记录内存分配时的代码段地址(调用堆栈地址,用 __builtin_return_address )
每次调用 malloc 时,创建一个新的文件;每次调用 free 时,删除对应的文件。
有文件存在,则表示该内存块未被释放,可能存在内存泄漏。我们查看里面的内容就知道是哪个了
可能的问题——printf,malloc死循环
- 由于 printf 本身会使用 malloc 来分配内存,如果你直接在 malloc 中使用 printf,会导致死循环,因为
printf 可能会再次调用 malloc,从而再次进入 malloc 的实现,形成递归调用。 - 所以在 malloc 函数中添加一个标识位,控制是否在内存分配时使用 printf。通过标识位来确保在 malloc 中调用 printf
时不会引发递归调用。
示例代码框架
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
// 标志位,防止 printf 导致死循环
bool malloc_in_progress = false;
void* my_malloc(size_t size) {
if (!malloc_in_progress) {
malloc_in_progress = true; // 标记 malloc 进行中,防止 printf 进入死循环
void* ptr = _libc_malloc(size); // 使用 libc 的底层 malloc 进行内存分配
malloc_in_progress = false; // 标记 malloc 完成
if (ptr) {
// 获取调用 my_malloc 的函数地址(即调用栈信息)
void* return_address = __builtin_return_address(0);
// 打开一个以内存地址为文件名的文件
FILE* f = fopen(reinterpret_cast<const char*>(&ptr), "w");
if (f) {
// 在文件中记录分配内存的信息,包括内存地址和调用的代码段地址
fprintf(f, "Memory allocated at address %p, called from %p\n", ptr, return_address);
fclose(f);
}
}
return ptr;
}
return nullptr; // 防止 printf 内部递归
}
void my_free(void* ptr) {
if (ptr) {
// 删除记录的文件,表示内存已被释放
remove(reinterpret_cast<const char*>(&ptr)); // 删除内存地址对应的文件
}
// 使用 libc 的 _free 进行内存释放
_libc_free(ptr);
}
int main() {
// 测试内存分配和释放
int* p = (int*)my_malloc(sizeof(int) * 10); // 分配内存
if (p) {
my_free(p); // 释放内存
}
return 0;
}
hook技术
技术介绍
hook技术又叫作钩子技术,它就是在程序运行的过程中,对其中的某个方法进行重写;在原有的方法前后加入我们自定义的代码。相当于在系统没有调用该函数之前,钩子程序就先捕获该消息,可以先得到控制权,这时钩子函数便可以加工处理(改变)该函数的执行行为。
基于修改sys_call_table的系统调用挂钩
Linux内核中所有的系统调用都是放在一个叫做sys_call_table的数组中,数组的值就表示这个系统调用服务程序的入口地址
将这个数组中存储的系统调用的地址改成我们自己的程序地址,就可以实现系统调用劫持(但是这个sys_call_table的内存页是只读属性)
- 步骤
获得sys_call_table的内存地址
grep sys_call_table /boot/ -r
关闭写保护
控制页表只读属性是由CR0寄存器的WP位控制的,只要将这个位清零就可以对只读页表进行修改
查找虚拟地址所在页表地址(lookup_address)
设置只读页表属性
- 修改sys_call_table
找到要修改的系统调用的调用号(sys_call_table数组的下标)(cat /usr/include/asm/unistd_64.h )
指针替换就好
- 向linux内核中添加编译的内核模块(Linux内核是Linux 5.3.0-40)
Linux内核开发所必需的头文件(3个init、module、kernel)
模块的许可证,module_license(查看完整列表)
编写init(加载)和exit(卸载),定义为静态的
调用 module_init 和 module_exit 函数告诉内核哪些函数是内核模块的加载和卸载函数
编写makefile,make
使用insmd将编译的才模块加载进内核进行测试
动态链接库的方法:
主要就是通过动态库的全局符号介入功能,用自定义的接口来替换掉同名的系统调用接口。由于系统调用接口基本上是由C标准函数库libc提供的,所以这里要做的事情就是用自定义的动态库来覆盖掉libc中的同名符号
- 外挂式
通过优先加自定义载动态库来实现对后加载的动态库进行hook,这种hook方式不需要重新编译代码
gcc编译生成可执行文件时会默认链接libc库,所以不需要显式指定链接参数(使用ldd命令查看可执行程序的依赖的共享库) - 步骤:
在不重新编译代码的情况下,用自定义的动态库来替换可执行程序的系统调用
建立一个c文件,里面设计的函数签名和libc提供的库函数一样(参数返回值之类的),可以用syscall的方式调用对应编号的系统调用,然后再添加几种内容
将这个.c文件编译成动态库(gcc -fPIC -shared hook.c -o libhook.so)
通过设置 LD_PRELOAD环境变量,将libhoook.so设置成优先加载,从面覆盖掉libc中的库函数(# LD_PRELOAD=“./libhook.so” ./a.out)
LD_PRELOAD环境变量,在指明在可执行程序运行之前,系统会优先把咱们自定义的动态库加载到程序的进程空间,使得在可执行程序之前,其全局符号表中就已经有了一个系统调用符号,这样在后序加载libc共享库时,由于全局符号介入机制,libc中的write符号不会再被加入全局符号表,所以全局符号表中的系统调用就变成 我们自己实现
- 侵入式
需要改造代码或是重新编译一次以指定动态库加载顺序
把改造的系统调用直接放在执行程序中
编译时,全局符号表里先出现的必然是可执行程序中的
如果不改造代码,那么可以重新编译一次,通过编译参数将自定义的动态库放在libc之前进行链接(gcc main.c -L. -lhook -Wl,-rpath=.)(由于默认情况下gcc总会链接一次libc,并且libc的位置也总在命令行所有参数后面,所以只需要像下面这样操作就可以了)
- 对于全局符号介入机制覆盖的系统调用接口,如何找回的方法
因为程序运行时,依赖的动态库无论是先加载还是后加载,最终都会被加载到程序的进程空间中,也就是说,那些因为加载顺序靠后而被覆盖的符号,它们只是被“雪藏”了而已,实际还是存在于程序的进程空间中的,通过一定的办法,可以把它们再找回来
可以使用Linux中的dslym方法找回被覆盖的符合
c++中怎么对内存泄露情况检查?怎么避免内存
gcc asan
hook
valgrind
RALL思想
一般公司有自己独特的工具
内存泄露造成的不利影响
中止运行
new和malloc的实现原理和区别
brk、mmap、munmap?在哪实现?数据结构?寻找方式?
new的过程(1、2、3)是什么?可被free?
段错误(coredump)
是什么?实际发生的情况有哪些?注意事项?
动态库静态库
结尾?
制作流程?
区别?
C++20无栈协程(以下是关于一个企业项目的技术)
线程和协程的区别??
线程是轻量级的进程,是资源调度的最小单位,虽然已经大幅度提高了并发能力,但是线程的切换仍然 有内核态切换的开销,当线程数量非常多的时候,有可能会适得其反
- 协程是轻量级用户态的线程,切换不需要经过内核态,由开发者控制切换时机,没有时间和资源开销
- 协程可以以同步变成的思维方式,来开发异步编程,降低了异步编程的难度
- 简单的异步编程我们可以直接在协程中处理,复杂的异步我们可以通过协程调用线程池处理
无栈协程是什么?
无栈协程 并非完全不需要栈,而是不需要独立的调用栈
普通的函数如果等待回调会在栈内维持一个上下文信息,如果回调地狱就会一直占用栈,无栈协程不是,他在堆上维护所有信息,调用的时候从堆里拿出来再去栈运行,必须运行函数肯定是要占用栈空间的,但是一旦不运行就彻底不占,只是有一个指向堆的指针,相当于挂起。
没有协程的时代
在没有协程的时代,为了应对 IO 操作,主要有三种模型
- 同步编程:
应用程序等待IO结果(比如等待打开一个大的文件,或者等待远端服务器的响应),阻
塞当前线程;
优点:符合常规思维,易于理解,逻辑简单;
缺点:成本高昂,效率太低,其他与IO无关的业务也要等待IO的响应;
- 异步多线程/进程:
将IO操作频繁的逻辑、或者单纯的IO操作独立到一/多个线程中,业务线程与IO
线程间靠通信/全局变量来共享数据;
优点:充分利用CPU资源,防止阻塞资源
缺点:线程切换代价相对较高,异步逻辑代码复杂
- 异步消息+回调函数:
设计一个消息循环处理器,接收外部消息(包括系统通知和网络报文等),
收到消息时调用注册的回调函数;
优点:充分利用CPU资源,防止阻塞资源
缺点:代码逻辑复杂
协程出现,解决回调地狱问题
协程是一个函数,它可以暂停以及恢复执行。按照我们对普通函数的理解,函数暂停意味着线程停止运行了(就像命中了断点一样),那协程的不同之处在哪里呢?区别在于,普通函数是线程相关的,函数的状态跟线程紧密关联;而协程是线程无关的,它的状态与任何线程都没有关系。
协程不用维护自己的调用栈,所有的状态信息存储在堆中,切换协程只需要保存当前上下文,恢复下一 个协程的上下文即可完成切换
有栈协程的切换需要维护自己的调用栈,支持复杂的调用,偏向于线程。
无栈协程更加的轻量,适合快 速的上下文切换。
- 只要是代码中出现了三个关键字,编译器自动把这段代码认为是协程,那就设计到两个技巧
1.调用到函数,这个函数的内部实现 有协程,结果就是这个函数被直接分离出去,程序继续往下执行
2.程序继续往下执行的时候,co_await协程返回的结果,这个函数的所有立即变成协程,等着(这叫嵌套)
协程的唤醒和销毁有两种方法,一种是用任务结构体里面的句柄,一种是Awaitable内执行 await_suspend() 后唤醒和直接调用co_return,他标志着会自动销毁。
c++20协程基础语法?
在C++中,只要在函数体内出现了 co_await 、co_return 和 co_yield 这三个操作符中的其中一个,这个函数就成为了协程。我们先来关注一下 co_await 操作符。
co_await 和 Awaitable
- co_await
的作用是让协程暂停下来,等待某个操作完成之后再恢复执行。在上面的协程示例中,我们对 IntReader 调用了 co_await 操作符,目前这是不可行的,因为 IntReader 是我们自定义的类型,编译器不理解它,不知道它什么时候操作完成,不知道如何获取操作结果。为了让编译器理解我们的类型,C++定义了一个协议规范,只要我们的类型按照这个规范实现好,就可以在 co_await 使用了。
这个规范称作
- Awaitable
它定义了若干个函数,传给 co_await 操作符的对象必须实现这些函数。这些函数包括:
- await_ready()
,返回类型是 bool。协程在执行 co_await 的时候,会先调用 await_ready() 来询问“操作是否已完成”,如果函数返回了 true ,协程就不会暂停,而是继续往下执行。实现这个函数的原因是,异步调用的时序是不确定的,如果在执行 co_await 之前就已经启动了异步操作,那么在执行 co_await 的时候异步操作有可能已经完成了,在这种情况下就不需要暂停,通过await_ready()就可以到达到这个目的。
- await_suspend()
,有一个类型为 std::coroutine_handle<> 的参数,返回类型可以是 void 或者 bool 。如果 await_ready() 返回了 false ,意味着协程要暂停,那么紧接着会调用这个函数。该函数的目的是用来接收协程句柄(也就是std::coroutine_handle<> 参数),并在异步操作完成的时候通过这个句柄让协程恢复执行。协程句柄类似于函数指针,它表示一个协程实例,调用句柄上的对应函数,可以让这个协程恢复执行。
- await_suspend()
的返回类型一般为 void,但也可以是 bool ,这时候的返回值用来控制协程是否真的要暂停,这里是第二次可以阻止协程暂停的机会。如果该函数返回了 false ,协程就不会暂停(注意返回值的含义跟 await_ready() 是相反的)。
- await_resume()
,返回类型可以是 void ,也可以是其它类型,它的返回值就是 co_await 操作符的返回值。当协程恢复执行,或者不需要暂停的时候,会调用这个函数。
预定义的Awaitable
C++预定义了两个符合 Awaitable 规范的类型: std::suspend_never 和 std::suspend_always 。顾名思义,这两个类型分别表示“不暂停”和“要暂停”,实际上它们的区别仅在于 await_ready() 函数的返回值, std::suspend_never 会返回 true,而 std::suspend_always 会返回 false。除此之外,这两个类型的 await_supsend() 和 await_resume() 函数实现都是空的。
这两个类型是工具类,用来作为 promise_type 部分函数的返回类型,以控制协程在某些时机是否要暂停。
协程的返回类型和 promise_type
现在我们把关注点聚焦在协程的返回类型上。C++对协程的返回类型只有一个要求:包含名为 promise_type 的内嵌类型。跟上文介绍的 Awaitable 一样, promise_type 需要符合C++规定的协议规范,也就是要定义几个特定的函数。 promise_type 是协程的一部分,当协程被调用,在堆上为其状态分配空间的时候,同时也会在其中创建一个对应的 promise_type 对象。通过在它上面定义的函数,我们可以与协程进行数据交互,以及控制协程的行为。
promise_type 要实现的第一个函数是 get_return_object() ,用来创建协程的返回值。在协程内,我们不需要显式地创建返回值,这是由编译器隐式调用 get_return_object() 来创建并返回的。这个关系看起来比较怪异, promise_type 是返回类型的内嵌类型,但编译器不会直接创建返回值,而是先创建一个 promise_type 对象,再通过这个对象来创建返回值。
那么协程的返回值有什么用呢?这取决于协程的设计者的意图,取决于他想要以什么样的方式来使用协程。例如,某示例中,PrintInt() 这个协程只是输出一个整数,不需要与调用者有交互,所以它的返回值只是一个空壳。 假如我们想实现一个 GetInt() 协程,它会返回一个整数给调用者,由调用者来输出结果,那么就需要对协程的返回类型做一些修改了。
c++20怎么实现协程?
总览
我在项目中实现一个协程,主要分为三个部分
c++20提供三个关键字,co_await、co_return、co_yield co_await作用于一个Awaitable对象,用于挂起当前协程。不阻塞当前线程的执行
co_return 提供协程返回的功能,可以返回空或者协程异步执行的结果
co_yield 提供在不结束协程的情况下,多次返回结果
promise_type(协程管理者)
作用:管理协程的生命周期、保存协程的返回值、处理协程内部的异常、确保协程正确结束、控制协程的挂起和恢复
需要定义的函数:
- get_return_object()
返回一个可以操作协程的对象,通常是协程的句柄。
协程执行的结果通过这个对象返回。 - initial_suspend()
定义协程第一次挂起时的行为。这个函数在协程开始时执行
在协程的起始时会暂停,直到被 co_await 等操作唤醒才开始干。 - final_suspend()
定义协程完成时的行为。当协程执行完毕,挂起或销毁时会调用此函数。
此函数通常确保协程完成时能够清理资源,或者决定协程是否需要进行最后一次挂起。 - return_value()
用于存储协程的返回值。
co_return 会将值传递到此函数。
他会直接结束协程 - yield_value()
用于存储协程的返回值。
co_return 会将值传递到此函数。
它会使协程暂停,并允许外部控制协程的恢复。 - unhandled_exception()
当协程内部抛出异常时,处理异常的函数。
它负责捕捉协程中的异常并做适当的处理,如中止程序或记录日志。
co_await(关键字,执行到此,协程先被挂起,而不是直接执行)
他比较奇怪的一点就是一般写什么代码运行到这里肯定是要进入这个代码运行的
但是他不是,走到他这里他先被挂起等别人唤醒才能运行
那他等谁呢,等的是可等待(Awaitable) 对象完成
所以他必须有一个可等待(Awaitable) 对象,而这个对象里就和一个结构体一样必须要定义一些函数
- await_ready()
返回true表示不用挂起,false需要挂起 - await_suspend()
函数内部主要实现需要异步执行的操作,执行完毕后,通过句柄唤醒协程 - await_resume()
协程恢复执行时的操作。
promise_type、co_await 和 Awaitable 之间的关系
promise_type:每个协程都必须有一个 promise_type,它负责管理协程的状态,包括返回值、异常和挂起操作。promise_type 提供了协程的生命周期管理方法,如 get_return_object()、return_value() 等。
co_await:协程在执行时,可以使用 co_await 等待某个 Awaitable 对象。co_await 会暂停协程并等待该对象的完成,只有该对象完成后,协程才能继续执行。
Awaitable:这是支持被 co_await 挂起和等待的类型。任何一个类型,只要提供了 await_suspend() 和 await_resume() 方法,就可以作为 Awaitable 类型被 co_await 使用。通常用于处理异步任务,如 I/O 操作。
- 一个例子
#include <iostream>
#include <coroutine>
#include <thread>
#include <chrono>
// Awaitable 类型:模拟一个异步操作
struct my_awaitable {
bool await_ready() const noexcept {
return false; // 返回 false 代表需要挂起
}
void await_suspend(std::coroutine_handle<> h) noexcept {
// 模拟异步操作,2秒后恢复协程
std::thread([h]() {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "Async operation completed, resuming coroutine...\n";
h.resume(); // 恢复协程
}).detach();
}
void await_resume() const noexcept {
std::cout << "Resumed from async operation!\n";
}
};
// 协程的 Promise 类型:管理协程生命周期
struct my_coroutine_promise {
int value;
my_coroutine_promise() : value(0) {}
// 协程启动时挂起
std::suspend_always initial_suspend() {
std::cout << "Initial suspend\n";
return {}; // 表示协程在开始时挂起
}
// 协程结束时不再挂起
std::suspend_never final_suspend() noexcept {
std::cout << "Final suspend\n";
return {}; // 协程结束后不再挂起
}
// 返回值函数:当协程通过 co_return 返回值时调用
void return_value(int v) {
value = v;
std::cout << "Returning value: " << value << std::endl;
}
// 异常处理函数
void unhandled_exception() {
std::cout << "Exception occurred in coroutine\n";
std::terminate();
}
// 获取协程对象:协程的外部代码可以通过它来控制协程
my_coroutine_promise* get_return_object() {
return this;
}
};
// 协程函数:带有 co_await 的协程
std::coroutine_handle<> my_coroutine() {
std::cout << "Coroutine started\n"; // 同步操作
co_await my_awaitable(); // 挂起协程,等待异步操作完成
std::cout << "Coroutine resumed\n"; // 同步操作
co_return 42; // 返回值并结束协程
}
int main() {
std::cout << "Main thread starts\n";
auto handle = my_coroutine(); // 创建协程
std::cout << "Coroutine created\n";
handle.resume(); // 启动协程,协程开始执行
std::this_thread::sleep_for(std::chrono::seconds(3)); // 等待协程完成
std::cout << "Main thread ends\n";
return 0;
}
//主程序第一句话的打印
Main thread starts
//到这发现my_coroutine()里面有关键字,直接自动创建协程,还有管理他的哪个任务结构体
Coroutine created
//创建了之后第一件事情就是进入到这个结构体的Initial suspend函数,发现是总是挂起,所以不执行任何的操作接着往下走
Initial suspend
//此刻走到了handle.resume(); 通过句柄唤醒了协程,真正进入my_coroutine()函数开始执行,此刻执行的所有代码都是协程干的,称其为,异步操作开始之前协程干的事儿
Coroutine started
//执行完异步操作开始之前协程干的事儿,碰到了co_await,于是进入awaitable结构体,执行异步操作
Async operation completed, resuming coroutine...
//执行完,唤醒了协程,协程从co_await后面接着执行,称之为异步操作开始之后协程干的事儿
Coroutine resumed
// 碰到了 co_return,直接调用管理协程的任务结构体的return_value函数,表示此时协程得到的结果,然后还知道了协程得到这个结果就要被挂掉了,(co_yield是继续等)
Returning value: 42
//挂掉了进入任务结构体的Final suspend函数,表示挂掉该干的事儿
Final suspend
//主程序最后一句
Main thread ends