单核心环境下Erlang两种gen_tcp监听模型的性能差异探究:gen_tcp accept vs OS线程accept
让我们先拆解这两种Erlang监听套接字与Acceptor的实现,再聚焦单核心场景下的性能差异问题:
第一种:单进程循环Accept模型
这是最直观的实现方式,代码逻辑非常简洁:
-module(listeners). % 省略初始化、配置等辅助代码 start() -> {ok, Listen} = gen_tcp:listen(....), % 创建监听套接字 accept(Listen). accept(Listen) -> {ok, Socket} = gen_tcp:accept(Listen), % 阻塞等待新连接 spawn(fun() -> handle(Socket) end), % 启动新进程处理连接 accept(Listen). % 回到循环等待下一个连接 handle(Socket) -> % 套接字数据读取、业务逻辑处理等代码 ....
逻辑解释:主进程创建监听套接字后,就进入一个死循环——每接收到一个客户端连接,就fork一个独立进程去处理这个连接的后续逻辑,自己立刻回到等待状态,准备接受下一个连接。
第二种:基于Supervisor的预启动Acceptor模型
这种实现依赖Erlang的监管树机制,提前启动多个Acceptor进程:
-module(listener). % 省略初始化、配置等辅助代码 start() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). % 启动监管进程 init([]) -> {ok, Listen} = gen_tcp:listen(....), % 创建监听套接字 spawn(fun() -> free_acceptors(5) end), % 启动临时进程,用于后续创建Acceptor子进程 % 配置simple_one_for_one类型的监管策略 {ok, {{simple_one_for_one, 5, 1}, [{child, {?MODULE, accept, [Listen]}, % 子进程启动选项(如临时进程、自动重启等) temporary, brutal_kill, worker, [?MODULE]}]}}. free_acceptors(N) -> % 批量启动N个Acceptor子进程 [supervisor:start_child(?MODULE, []) || _ <- lists:seq(1, N)], ok. accept(Listen) -> {ok, Socket} = gen_tcp:accept(Listen), % 阻塞等待新连接 handle(Socket). % 处理连接(处理完后会再次调用accept等待) handle(Socket) -> % 套接字数据读取、业务逻辑处理等代码 ....
逻辑解释:主进程启动一个simple_one_for_one类型的Supervisor,在监管进程的init/1方法中创建监听套接字,然后通过一个临时进程启动5个Acceptor子进程(因为Supervisor在初始化阶段无法直接启动子进程,必须等自身初始化完成后再操作)。这5个Acceptor进程会各自调用gen_tcp:accept/1,同时等待新连接到来。
单核心场景下的性能疑问与本质分析
我一开始也想当然认为第二种模型在单核心机器上处理5个并发连接会更快——毕竟5个Acceptor同时在等,看起来能并行接受连接,而第一种得一个接一个处理,第5个客户端要等前4个都被Accept完才能轮到。但深入研究ERTS(Erlang运行时系统)的工作原理后,这个想法就站不住脚了:
- 单核心下的Erlang进程是并发而非并行:单核心机器上,ERTS默认只会启用一个OS线程作为调度器,所有Erlang进程都是通过调度器的快速切换来实现并发执行,看起来像并行,但本质上同一时间只有一个进程在执行。
- OS层面的Accept操作是串行的:套接字是OS层面的资源,
gen_tcp:accept/1最终会调用OS的accept系统调用。而OS对同一个监听套接字的accept调用有个硬性限制:同一时间只能有一个accept调用在处理连接请求。哪怕你启动了5个Erlang进程去调用accept,这些调用最终都会在OS层面排队,由单个OS线程依次处理。
结论:单核心下两种实现的性能差异微乎其微
回到问题场景:当5个客户端同时连接两种服务器时,两种模型的处理本质是一致的——所有连接请求都会被OS依次Accept,Erlang进程的调度切换不会改变OS层面的处理顺序。
两种实现的性能差异几乎可以忽略:
- 第一种模型的主进程在Accept后Spawn处理进程,然后立刻回到Accept循环,调度开销极小。
- 第二种模型的5个Acceptor进程在等待时会被调度器挂起,当有连接进来时,OS唤醒其中一个Acceptor完成Accept,处理完后该Acceptor会再次调用Accept等待下一个,这里的调度切换开销和第一种模型相比没有显著差别。
最后补充个小细节:业界知名的Erlang项目中,Ejabberd采用第一种单循环模型,而Cowboy采用第二种预启动Acceptor模型。这更多是架构设计的选择而非单核心性能的考虑——Cowboy的模型在多核机器上可以充分利用多个调度器(ERTS会为每个核心启动一个OS线程),让不同的Acceptor进程在不同核心上并行处理连接;而Ejabberd的单循环模型更简洁,在稳定场景下足够可靠。
内容的提问来源于stack exchange,提问作者Michael

