看的 envoy 代码版本:
v1.19
envoyproxy/envoy at release/v1.19 (github.com)v1.25 的开发分支
1. Overview of Envoy’s architecture
个人理解:
代理软件/rpc框架 = 路由器 + 过滤器
envoy = 代理软件 + 规则引擎
envoy 内部的"面向对象设计"如下图:
出自 https://icloudnative.io/envoy-handbook/docs/gettingstarted/architecture/
出自 https://www.envoyproxy.io/docs/envoy/latest/intro/life_of_a_request
出自 https://medium.com/@alishananda/implementing-filters-in-envoy-dcd8fc2d8fda
2. 线程模型
Event loop per thread
Envoy内部每一个worker线程都会有一个事件循环, 即 "Event loop per thread"。
个人喜欢把这种设计看成“多线程版的 nodejs”,或者 “多线程版的 redis”。
worker 线程的 event loop 流程大致如下:
加上 read 事件,就是这样:
(出自 https://istio-insider.mygraphql.com/zh_CN/latest/ch2-envoy/arch/event-driven/event-driven.html 我做了一些补充)
除了 worker 线程,还有 flush 线程负责写文件 IO,这在 event loop 模型中很常见:有些 OS 不支持异步 IO,但是 event loop 又不希望出现阻塞,因此通过隔离出独立的 IO 线程池来做阻塞 IO。
详细介绍见 https://blog.envoyproxy.io/envoy-threading-model-a8d44b922310
Event loop 模型的常见问题
像 nodejs 这样的 Event loop 模型,会遇到以下常见问题:
怎么利用多核?
如何避免调用阻塞函数?
开发者可能失误、调用了阻塞函数,或者用的三方库悄悄调用了会阻塞的系统调用,导致event loop hang 住。如何避免这种情况?按照什么样的策略去调度事件?
事件那么多,得对事件合理“排序”,保证公平性、提高吞吐。例如,会不会出现一种极端情况:某个请求的事件一直得不到调度,导致“饥饿”?编程模型复杂(例如会出现回调地狱问题,例如 callback 会导致思维跳跃、心智负担高)如何简化开发、提高代码可维护性?
利用多核: 多线程版的 nodejs
nodejs 如何利用多核?
nodejs 是单线程 event loop,想利用多核需要启动多进程。
朴素的方案是启动多个 nodejs 实例,缺点是会监听不同端口,没法做到“单端口、利用多核”。
为了解决这个问题,就有了更高级的方案:主进程fork 出多个子进程,但是主进程监听端口,获得新连接后将 socket 通过 IPC 的方式传递给子进程:
见:
https://www.ucloud.cn/yun/34781.html
https://www.jb51.net/article/211004.htm
https://cloud.tencent.com/developer/article/1518004
envoy 如何利用多核?
为了利用多核,envoy 的方案也是启动多个 event loop;而为了实现“单端口、利用多核”, envoy 的每个 event loop 线程会去 epoll_wait 同一个 socket。很神奇。
Q: envoy 是怎么让多个worker 线程 epoll_wait 同一个 socket 的?
A: 启动时,main thread 先 bind socket,然后通过复制 fd 或者 reuse_port 的方式让每个worker 都能监听相同port 的socket.
https://blog.mygraphql.com/zh/posts/low-tec/trace/trace-istio/trace-istio-part2/#listener
Q: 复制 fd 是什么机制?
A: 详见 https://blog.mygraphql.com/zh/posts/low-tec/trace/trace-istio/trace-istio-part2/#原理
Q: 什么是SO_REUSEPORT ?
A: 简单来说,就是多个 server socket 监听相同的端口。每个 server socket 对应一个监听线程。内核 TCP 栈接收到客户端建立连接请求(SYN)时,按 TCP 4 元组(srcIP,srcPort,destIP,destPort) hash 算法,选择一个监听线程,唤醒之。新连接绑定到被唤醒的线程。所以相对于非 SO_REUSEPORT, 连接更为平均地分布到线程中(hash 算法不是绝对平均)。
详见 https://blog.mygraphql.com/zh/posts/cloud/istio/istio-tunning/istio-thread-balance/
Q: 怎么保证多个 worker线程的负载均衡?
A: 设计目标是“每个线程绑定的连接数尽量一样”,如果 A、B有同样数量的连接数,但是 A 的连接很忙,B的连接很闲,那没办法。
默认情况下,envoy 让内核做负载均衡。
但问题是,kernel 只能做“同一时间来的请求均匀分给不同的线程”,而在长连接场景,假如 A 先接收一个长连接后,过了几分钟有新的连接,我们希望新连接交给别的线程、不要交给 A。也就是说,需要根据“当前状态”做LB,而 kernel 不会帮忙管这些状态。
为了解决这个问题,Envoy 有种特殊配置 (ConnectionBalanceConfig),在用户态保证连接的负载均衡。
大致原理是:一个 listener 线程 accept到连接后,进行负载均衡计算,如果算出来“我的负载太高,这个连接应该交给 B线程",会转发给 B 线程的 listener
如何避免调用阻塞函数?
Q: 开发者可能失误、调用了阻塞函数,或者用的三方库悄悄调用了会阻塞的系统调用,导致event loop hang 住。如何避免这种情况?
A: 只能开发者自己注意,需要 IO 的时候统一用 envoy 的API
所以这种设计其实不利于大规模多人协作,也很难使用三方库
如何避免回调地狱、提高代码可维护性?
开发者写代码时候自己注意,尽量只有一层 callback,避免嵌套 callback
除了嵌套地狱,callback 还有个更现实的问题:思维跳跃,会增加读代码、写代码的难度
3. 启动流程
这里概况一下流程,运行 main 后,大致会做以下几步事情:
组装对象模型, bind 端口
main 里一顿操作,最终会组装出 Server::Instance.
Server::Instance 的类图:
Server::InstanceImpl 肚子里有 ListenerManager
而各种 Listener、worker池都存在 ListenerManagerImpl 的肚子里。
关键代码:
-
listener_manager_
初始化时新建options().concurrency()个worker,每个worker有自己独立的dispatcher:
//source/server/worker_impl.cc
WorkerPtr ProdWorkerFactory::createWorker(OverloadManager& overload_manager) {
Event::DispatcherPtr dispatcher(api_.allocateDispatcher());
return WorkerPtr{new WorkerImpl(
tls_, hooks_, std::move(dispatcher),
Network::ConnectionHandlerPtr{new ConnectionHandlerImpl(ENVOY_LOGGER(), *dispatcher)},
overload_manager, api_)};
}
后面服务器启动后,每个 worker 会在自己的线程上跑 dispatcher loop (或者叫 event loop).
更详细的类图见 https://blog.csdn.net/zhangpin04/article/details/104367752
详细代码走读见
https://blog.csdn.net/wyy4045/article/details/119111541
https://blog.csdn.net/tony_shephard/article/details/127152454
- 构造过程中,会创建 socket、bind 到 ip 和 port
// source/common/network/listen_socket_impl.cc
void ListenSocketImpl::setupSocket(const Network::Socket::OptionsSharedPtr& options) {
setListenSocketOptions(options);
bind(connection_info_provider_->localAddress());
}
详细调用关系太长了,可以看这个图:
[email protected] (5451×3993) (mygraphql.com)
出自 逆向工程与云原生现场分析 Part2 —— eBPF 跟踪 Istio/Envoy 之启动、监听与线程负载均衡 – Mark Zhu 的博客 (mygraphql.com)
Server 启动, 创建线程池
组装好后,main 会调 Server::InstanceImpl::run() 启动;
经过各种回调后,最终会调用到 ListenerManagerImpl::startWorkers() 函数,负责:
- 把所有的listener添加到每个worker中
- 调
worker->start
, 将所有 worker 线程都启动起来
关键代码:
void ListenerManagerImpl::startWorkers(GuardDog& guard_dog, std::function callback) {
//......
// 把所有的listener添加到每个worker中。
for (auto listener_it = active_listeners_.begin(); listener_it != active_listeners_.end();) {
auto& listener = *listener_it;
listener_it++;
// ......
for (const auto& worker : workers_) {
// ① 这步会往 worker 的任务队列里加一个任务,让 worker 去 listen socket
addListenerToWorker(*worker, absl::nullopt, *listener,
[this, listeners_pending_init, callback]() {
if (--(*listeners_pending_init) == 0) {
stats_.workers_started_.set(1);
callback();
}
});
}
}
for (const auto& worker : workers_) {
ENVOY_LOG(debug, "starting worker {}", i);
// ② 启动 worker 线程
worker->start(guard_dog, worker_started_running);
if (enable_dispatcher_stats_) {
worker->initializeStats(*scope_);
}
i++;
}
相关代码解释:
① 这步会调用到 WorkerImpl::addListener
,这函数会往 worker 的任务队列里加一个 addListener 任务,供 worker线程启动后消费。
v1.19 里, 这任务会 listen socket, 然后注册事件回调函数,等可以 accept socket 后回调
(我看 v1.25 的代码已经变了,不在这 listen socket 了。具体细节不再细究)
void WorkerImpl::addListener(absl::optional overridden_listener,
Network::ListenerConfig& listener, AddListenerCompletion completion,
Runtime::Loader& runtime) {
dispatcher_->post([this, overridden_listener, &listener, &runtime, completion]() -> void {
// 这步最终会调用到 listen socket
handler_->addListener(overridden_listener, listener, runtime);
hooks_.onWorkerListenerAdded();
completion();
});
}
最终会调用到 TcpListenerImpl::setupServerSocket
void TcpListenerImpl::setupServerSocket(Event::DispatcherImpl& dispatcher, Socket& socket) {
ASSERT(bind_to_port_);
// 在这 listen socket
socket.ioHandle().listen(backlog_size_);
// 注册事件处理器,等到可以 accept socket 的时候触发,回调 `onSocketEvent`, 进行 accept
socket.ioHandle().initializeFileEvent(
dispatcher, [this](uint32_t events) -> void { onSocketEvent(events); },
Event::FileTriggerType::Level, Event::FileReadyType::Read);
if (!Network::Socket::applyOptions(socket.options(), socket,
envoy::config::core::v3::SocketOption::STATE_LISTENING)) {
throw CreateListenerException(fmt::format("cannot set post-listen socket option on socket: {}",
socket.addressProvider().localAddress()->asString()));
}
}
调用链实在太长,详见 [email protected] (5451×3993) (mygraphql.com)
② WorkerImpl::start 做的事有:
- 创建 thread
- 创建完成后执行回调,调 threadRoutine 启动 dispatcher, 开始 event loop
void WorkerImpl::start(GuardDog& guard_dog, const Event::PostCb& cb) {
ASSERT(!thread_);
Thread::Options options{absl::StrCat("wrk:", dispatcher_->name())};
thread_ = api_.threadFactory().createThread(
[this, &guard_dog, cb]() -> void { threadRoutine(guard_dog, cb); }, options);
}
worker 线程启动 event loop
WorkerImpl::threadRoutine
做的事情就是调用 dispatcher, 启动 event loop:
void WorkerImpl::threadRoutine(GuardDog& guard_dog, const Event::PostCb& cb) {
ENVOY_LOG(debug, "worker entering dispatch loop");
// 往任务队列里加一个任务
dispatcher_->post([this, &guard_dog, cb]() {
cb();
watch_dog_ = guard_dog.createWatchDog(api_.threadFactory().currentThreadId(),
dispatcher_->name(), *dispatcher_);
});
// 启动 event loop
dispatcher_->run(Event::Dispatcher::RunType::Block);
// 下面是一些关闭逻辑
ENVOY_LOG(debug, "worker exited dispatch loop");
guard_dog.stopWatching(watch_dog_);
dispatcher_->shutdown();
handler_.reset();
tls_.shutdownThread();
watch_dog_.reset();
}
dispatcher_ 会启动 event loop.
Envoy和Nginx一样都是基于事件驱动的架构,这种架构的核心就是事件循环(EventLoop)。业界目前典型的几种事件循环实现主要有Libevent、Libev、Libuv、Boost.Asio等,也可以完全基于Linux系统调用epoll来实现。Envoy选择在Libevent的基础上进行了封装,实现了自己的事件循环机制,在Envoy中被称为Dispatcher,一个Dispatcher对象就是一个事件分发器
https://developer.aliyun.com/article/757470?spm=a2c6h.13262185.profile.8.d5bc6cc5iOyrJC
至于 Dispatcher 内部实现,详细分析见 https://developer.aliyun.com/article/757470?spm=a2c6h.13262185.profile.8.d5bc6cc5iOyrJC
我没细看,似乎文章中代码已经和最新版不一样了。
worker 线程取出任务队列的addListener 任务,执行
上文已述,addListener 任务会:
- listen socket
- 注册事件回调函数,等可以 accept socket 后回调
4. 请求处理流程: 从新连接建立,到回调 network filter
可以先看 https://blog.envoyproxy.io/taming-a-network-filter-44adcf91517
以便了解大概流程。
epoll 通知: 可以 accept 啦
新连接建立、握手成功后,epoll_wait 返回,回调通知:可以 accept 啦
详细流程如下:
解释:
https://blog.mygraphql.com/zh/posts/low-tec/trace/trace-istio/trace-istio-part3/#tcp-%E8%BF%9E%E6%8E%A5%E5%BB%BA%E7%AB%8B%E6%AD%A5%E9%AA%A4
创建 network filter
涉及流程包括:
调用每个 filter 的工厂函数
Network::FilterFactoryCb
,创建 filter、filter_manager.addReadFilter(filter)
加进 filter chainaddReadFilter 会回调
filter->initializeReadFilterCallbacks
, 方便 filter 在构造后获取downstream 连接等对象,方便 filter 把连接相关状态、相关对象存到肚子里,以及改连接的一些参数
onNewConnection()
创建了 NetworkFilterChain 后,会调用 FilterManager::initializeReadFilters
bool FilterChainUtility::buildFilterChain(Network::FilterManager& filter_manager,
const std::vector& factories) {
for (const Network::FilterFactoryCb& factory : factories) {
factory(filter_manager);
}
return filter_manager.initializeReadFilters();
}
FilterManager::initializeReadFilters
方法会调用所有 Read Filter 的 onNewConnection()
onData
上文提到过,匹配到最合适的 network filter chain 配置后,会创建 ServerConnection 对象 (ConnectionImpl的子类) ,它在构造函数中会注册 socket 事件监听,有新事件会回调到 Network::ConnectionImpl::onFileEvent(uint32_t events) 中。即以后的 socket 事件将由这个ServerConnection处理。
相关构造函数:
ConnectionImpl::ConnectionImpl(Event::Dispatcher& dispatcher, ConnectionSocketPtr&& socket,
TransportSocketPtr&& transport_socket,
StreamInfo::StreamInfo& stream_info, bool connected)
// 忽略......
// We never ask for both early close and read at the same time. If we are reading, we want to
// consume all available data.
socket_->ioHandle().initializeFileEvent(
dispatcher_, [this](uint32_t events) -> void { onFileEvent(events); }, trigger,
Event::FileReadyType::Read | Event::FileReadyType::Write);
// 忽略......
}
注: IoHandleImpl::initializeFileEvent 做的事就是封装一个 FileEventImpl
void IoHandleImpl::initializeFileEvent(Event::Dispatcher& dispatcher, Event::FileReadyCb cb,
Event::FileTriggerType trigger, uint32_t events) {
ASSERT(user_file_event_ == nullptr, "Attempting to initialize two `file_event_` for the same "
"file descriptor. This is not allowed.");
ASSERT(trigger != Event::FileTriggerType::Level, "Native level trigger is not supported.");
user_file_event_ = std::make_unique(dispatcher, cb, events, *this);
}
FileEventImpl是 envoy 基于 libevent 封装的“事件”,会在构造时调 Libevent 的 api 注册事件监听。更详细的解释见 https://developer.aliyun.com/article/757470?spm=a2c6h.13262185.profile.8.d5bc6cc5leQj9q
有新事件后,libevent 回调到 Network::ConnectionImpl::onFileEvent
,在这个方法里根据事件类型做分发,读事件会分发到 ConnectionImpl::onReadReady
, 做的事情有:
- First, it appends the new chunk into the “read” buffer.
- 回调到
FilterManagerImpl::onRead
,核心逻辑是遍历 filter chain、调用onData()
- The “read” buffer will normally be drained by the terminal filter in the chain (e.g., TcpProxy).
- However, if one of the filters in the chain returns StopIteration without draining the buffer, the data will remain buffered.
其中,FilterManagerImpl::onRead
回调 filter chain 的代码:
void FilterManagerImpl::onContinueReading(ActiveReadFilter* filter,
ReadBufferSource& buffer_source) {
// 忽略……
for (; entry != upstream_filters_.end(); entry++) {
if (!(*entry)->filter_) {
continue;
}
if (!(*entry)->initialized_) {
(*entry)->initialized_ = true;
FilterStatus status = (*entry)->filter_->onNewConnection();
if (status == FilterStatus::StopIteration || connection_.state() != Connection::State::Open) {
return;
}
}
StreamBuffer read_buffer = buffer_source.getReadBuffer();
if (read_buffer.buffer.length() > 0 || read_buffer.end_stream) {
FilterStatus status = (*entry)->filter_->onData(read_buffer.buffer, read_buffer.end_stream);
if (status == FilterStatus::StopIteration || connection_.state() != Connection::State::Open) {
return;
}
}
}
}
关键代码 filter_->onData
就是在调用 ReadFilter::onData
相关设计: buffer 设计
如上所述,connection 内部维护了读buffer、写 buffer:
以 http2 为例:
buffer 用的是 envoy 自己轮的数据结构,细节不展开。
相关设计: flow control 机制
To prevent the “write” buffer from overflowing, Envoy implements a concept of flow control (also known as “backpressure”). Its purpose is to stop receiving data from the remote side (i.e., Upstream) if the local buffer is full (i.e., “write” buffer on the “downstream” connection).
Since these filters are responsible for forwarding data to Upstream, they are also in charge of implementing flow control: if the “write” buffer on the “downstream” connection gets full, then stop receiving data from the Upstream. Similarly, if the “write” buffer on the “upstream” connection gets full, then stop receiving data from the Downstream.
Taming a Network Filter. This blog post continues the series… | by Yaroslav Skopets | Envoy Proxy
Flow control in Envoy is done by having limits on each buffer, and watermark callbacks. When a buffer contains more data than the configured limit, the high watermark callback will fire, kicking off a chain of events which eventually informs the data source to stop sending data.
envoy/flow_control.md at 357bf0016ffa0102ed1fc3c51687eb23b9d2305d · envoyproxy/envoy · GitHub
推荐阅读的参考资料
- 作者的博客
https://medium.com/@mattklein123
TODO
- 用 strace 分析系统调用
- 用 ebpf 分析系统调用