0 简单介绍项目
什么是异步效果?
为fd注册读事件后,直接返回,执行其他任务。事件发生后,回调函数会被自动调度,进行read操作。对于用户来说,读的过程不需要阻塞等待。
基于简历的问题
1 RAII
Resource Acquisition is Initialization,资源获取即初始化。
使用类来管理资源,在构造函数中申请分配资源,在析构函数中释放资源,将资源和对象的生命周期绑定。智能指针是RAII最好的例子。
范围锁:基于RAII,在构造函数中上锁,在析构函数中解锁。
2 锁
2.1 项目中哪里使用锁?
工作线程从任务队列中取任务,需要互斥锁(比读写锁更安全)
读写全局变量,使用读写锁
2.2 锁的种类
互斥锁
读写锁
自旋锁:循环等待获取锁
信号量、条件变量
数据库中的锁:
- 悲观锁:假定访问数据时冲突才是常态,访问数据之前就上锁,防止其它线程的操作,直到当前事务结束。适合写多读少的场景。
- 乐观锁:假定访问数据时冲突较少,访问数据时不加锁,而是在更新数据之前检查数据是否被其它事务更改过,只有数据未修改,当前操作才会成功。
- 行锁、表锁、共享锁、排他锁
2.3 为什么不用c11的线程和锁
std::thread也是基于pthread实现的,但是在c11中没有读写锁,这在多线程开发中需要频繁使用,所以选择自己封装。
tips:c++14中提供了读写锁。
c++11中的锁:
- std::lock_guard:对象创建,自动上锁,对象析构,自动解锁。
- std::unique_lock:在lock_guard的基础上,用户可以手动上锁和解锁。
3 ucontext_t接口
4 对称协程、非对称协程
5 有栈协程、无栈协程
6 独立栈、共享栈
7 为什么只有三种状态
- 模型更加简洁,易于管理和实现。
- 降低维护成本,减少了出错的可能。
8 定时器的实现方式
9 线程池的实现
本项目中,调度器包含调度线程和任务队列,调度器中的线程池是一个线程数组,调度器本身行使了传统线程池的任务。
传统的线程池包含以下几个要素:
- 工作线程
- 任务队列
- 线程同步
c++11线程池示例
#include<queue>
#include<thread>
#include<mutex>
#include<conditon_variable>
#include<functional>
#include<vector>
class ThreadPool {
public:
ThreadPool(size_t threadCount) : stop(false) {
for (size_t i = 0; i < threadCount; ++i) {
//根据参数,调用对应的构造函数
workers.emplace_back([this] {
//工作函数的逻辑
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queueMutex);
//释放lock;被唤醒后,也要匿名函数返回true,才能继续执行,否则继续等待
condition.wait(lock, [] {return stop || !task.empty(); });
if (stop && task.empty())
return;
//移动
task = std::move(tasks.front());
tasks.pop();
}
task();
}
});
}
}
~Thread_pool() {
{
std::unique_lock<std::mutex>lock(queueMutex);
stop = true;
}
condition.notify_all();
for (std::thread& worker : workers) {
worker.join();
}
}
void enqueue(std::function<void()> f) {
{
std::unique_lock lock(queueMutex);
task.emplace_back(std::move(f));
}
condition.notify_one();
}
private:
std::vector<std::thread> workers;
//返回类型void,()中是参数类型,无参数
std::queue<std::function<void()>> tasks;
std::mutex queueMutex;
std::condition_vairable condition;
bool stop;
};
void printHello(int id) {
std::cout << "Hello from thread " << id << std::endl;
}
int main() {
ThreadPool pool(4);
for (int i = 0; i < 10; ++i) {
pool.enqueue([i] {printHello(i); });
}
return 0;
};
10 hook的作用,怎样实现?
hook将原来的api进一步封装,在执行真正的系统调用之前,执行一些隐藏的操作,从而实现异步的效果,如下:
hook socket IO,read/write:如果暂时无法读/写,则为fd注册对应事件,yield,等待事件发生后,再把这个协程添加到调度队列。
用户不需要使用epoll进行监测,可使用顺序编程的方法,实现异步的效果。IO调用返回后,IO就已经完成,中间过程不需要用户参与。
int fd = accept()
read(fd)
...
write(fd)
...
其它hook:
- socket:如果用户没有设置为阻塞模式,则设置fd为非阻塞
- sleep:注册定时事件,然后yield,超时后,再把这个协程添加到调度队列
11 用了什么设计模式?
单例模式。
创建fd管理类FdCtx,标识fd是否阻塞,是否是socket fd,是否关闭;
创建FdCtx集合类FdManager管理所有FdCtx,FdManager使用单例模式。
为什么要使用单例模式?
主要是为了提供一个全局访问点,方便访问;保证全局仅有一份实例,节约内存资源;避免多个实例产生冲突。
FdManager是全局使用的,并不是某个类的成员,似乎可以使用全局变量来实现。但是:
- 全局变量可能会被拷贝,导致内存中出现多个实例,浪费内存;
- 遵循设计模式,代码更规范。
注意:单例模式本身无法避免线程同步问题,需要使用互斥锁等
单例模式的优点:
1 IO模型
1.1 同步
1.1.1 阻塞IO
用户执行read,会阻塞等待数据准备好、数据从内核拷贝到应用进程两个过程,拷贝完成,read才会返回。
1.1.2 非阻塞IO
read在数据未准备好时,立即返回,并设置错误码 EAGAIN/ EWOULDBLOCK,此时应用程序不断轮询内核,直到数据准备好,然后将数据从内核拷贝到用户缓冲区中,read返回。
read最后一次,需要等待数据从内核拷贝到用户缓冲区,这是同步的过程。
1.1.3 IO多路复用
I/O 多路复用接口最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。
同样,read需要等待数据从内核拷贝到用户缓冲区,这是同步的过程。
1.2 异步
异步IO,内核数据准备好、数据从内核拷贝到用户态,都不需要等待。
用户调用aio_read后,立即返回,内核自动准备数据并拷贝到用户态,不需要等待,这是个异步的过程。拷贝完成后,通知用户。
1.3 总结
同步IO一定会阻塞在【过程2】,而异步IO不会阻塞。
2 进程、线程、协程
进程是操作系统进⾏资源分配的基本单位,每个进程都有⾃⼰的独⽴内存空间;
线程是cpu调度的基本单位,线程共享父进程的虚拟地址空间;
协程是用户态线程,协程通常在线程中运行
2.1 切换上下文
进程
cpu上下文——寄存器
虚拟内存相关——页表
资源相关——文件句柄
线程
线程切换不需要切换虚拟地址空间,
只需要切换cpu寄存器上下文和少量的资源管理上下文。
协程
部分cpu寄存器,
如当前调用栈栈基地址、代码的执行位置等,当前的上下文保存到线程的堆区。
2.2 多进程/线程/协程
多进程
fork()创建子进程,子进程拷贝父进程地址空间,写时复制,代码段相同,执行任务相同。
exec 系列函数可以在子进程中加载新的可执行程序,将子进程的代码替换为新程序的代码。这样,子进程将执行与父进程不同的任务。
多线程
父进程创建多个线程,每个线程有自己的入口函数,执行不同的任务。多个线程共享父进程的资源。
协程
每个协程由自己的入口函数,执行不同的任务。
协程通常是在单线程中运行的,协程可以在线程中实现切换,开销比线程和进程切换小,可以实现高并发。
协程经常与多线程一起使用。
3 协程优缺点
协程优点
轻量级,创建和销毁开销小;
占用资源少,同一时间可以维持更多的并发单元,提高程序的并发性能;
相对于线程,协程上下文切换开销小,速度快,在高并发IO密集场景下,能显著提升系统的吞吐量和响应时间;
用户使用同步方式编程,就可以实现异步的效果,简化编程的复杂性。参考10hook
缺点
⽆法利⽤多核资源:线程才是系统调度的基本单位,单线程下的多协程本质上还是串⾏执⾏的,只能⽤到单核计算资源,所以协程往往要与多线程、多进程⼀起使⽤。
难以调试:由于协程的切换和异步执行,调试协程代码可能更加困难。当协程之间存在复杂的依赖关系和交互时,追踪问题的根因可能变得复杂。
4 协程适用于I/O密集型任务的原因
非阻塞IO条件下,等待IO的过程中,会切换到其它任务继续执行:
相比线程,协程切换快速,开销小;
协程轻量级,占用资源少,可以创建大量协程,处理并发,不会像线程一样收到资源的限制。
5 协程实现的是真正的异步吗?
底层使用的是同步非阻塞IO,还是同步的,但是可以实现异步的效果,调用返回后,数据就已经读取完成,期间,不需要用户干预。
6 衡量⼀个协程库性能的标准
响应时间
吞吐量
并发能力:同时处理的协程数量
上下文切换开销
资源利用率:cpu,内存,网络等。可以在资源利用的方面进行优化,从而提升性能。
7 Go协程
Go从语言层面支持协程,Goroutine就是Go中最基本的执行单元。每一个Go程序至少有一个Goroutine,从main函数开始,Go程序会为main函数创建一个默认的Goroutine。
8 C++协程
是c++20引入一种语言新特性,通过co_await和co_yield实现
9 为什么要有空闲协程
在任务队列为空时,阻塞在idel协程中的epoll_wait中。idel协程负责使用epoll监听事件,实际发生后,将对应回调函数添加到调度队列中。
调度协程只负责任务调度,idel协程负责添加任务,这样,降低了不同功能之间的耦合,便于后序扩展和维护。
10 每建⽴⼀个⽤户连接就要创建⼀个协程,不会影响性能吗?
会的,高并发时,会有大量的协程创建和销毁,会占用较多系统资源。
可使用协程池的方法解决。提前创建一定数量的协程,有新的任务时,直接复用已有的协程。
11 测试+优化
11.1 测试
测试方法:使用原生epoll和本项目的协程库,分别在单线程和多线程条件下,编写服务端程序。服务端接收到请求后,回复一个简单的页面。
测试工具:apachebench(ab)
结论:
- 单线程情况下,相比于直接使用epoll,性能并无太大差异。
- 多线程情况下,在IO密集、线程或协程切换情况较多时,使用协程有明显的性能优势。
11.2 优化——协程池
每个调度线程中,创建一个协程池。
开始调度之前,先创建一定数量的协程;
调度时,选择空闲的协程绑定任务进行调度。如果没有空闲的协程,则创建一个新的协程,可以选择是否添加进入协程池。
协程池的存在,避免了部分协程的创建和析构,在一定程度上提升了系统的性能。
协程池中协程的数量选择:
如果不需要等待IO,或任务执行的过程中,不需要yield:因为在线程中,协程是串行执行的,执行完一个,再执行另一个。同一时间,只有一个协程在执行,且没有处于挂起状态的协程(本项目中是ready状态),那么,协程池中只需要一个协程就足够,提升协程的数量不能提升性能;
如果,需要等待IO,或任务执行的过程中,需要yield:同一时刻,有大量被挂起的协程,还有一个正在执行的协程,那么,不考虑内存影响,协程池的协程数量越多,性能提升越大。为了简单,本项目选择创建新的协程,并且新的协程不添加入协程池。
12 困难
12.1 协程的调度
首先需要考虑协程的切换:
本项目使用非对称协程模型,子协程只能和调度协程切换,调度协程再选择新的子协程进行调度。子协程不能直接resume子协程。
然后是调度器的设计:
调度器包含调度线程池和任务队列。调度线程包含两个主要协程,分别是调度协程和idel协程。任务队列非空时,调度协程从任务队列中取任务进行调度;调度队列为空时,调度协程切换到idel协程, 阻塞在epoll_wait,释放cpu,避免忙等。IO事件发生后,idel协程负责将任务添加到任务队列。
调度器停止:
需要等待任务队列为空,并且所有调度线程都执行完毕
12.2 hook
对IO系统调用进行封装,如果阻塞,则为fd注册对应事件,当事件发生后,将回调函数添加到任务对队列中进行调度。这样,用户可以使用同步的编程方式,实现异步的效果。
13 收获
深入了解了协程,熟悉独立栈/共享栈,对称/非对称协程的概念
对进行、线程加深了理解
了解了Linux网络编程,了解IO多路复用、事件驱动模型。
14 其它协程库
c++20协程
go协程
Boost.Coroutine2
libco:腾讯