如何管理大量TCP连接
本章的目标是介绍如何管理大量的TCP连接(UDP要用好真不简单,功力有限,抛开不谈),思路如下:
- 先说挑战,从几个经典问题看起。
- 再说理论,看Linux操作系统几种经典的I/O模型是如何解决这些问题的
- 然后说下代码实现
- 最后会深入探讨下万、十万和百万级别连接管理的架构
挑战
经典问题:C10K和C10M
引言
我印象中小学(2003年)时代一个月才有1次的计算机打字课(进去还要换拖鞋,也是很奇怪),是用的大头机和windows98系统:
到初中年代(2008),县城里的网吧也还有许多电脑是这种大头机(辐射非常大,玩一会头晕眼花),然后慢慢的才有液晶显示器,直到大学才有自己的第一款笔记本(2010)。
也是从2010年后,自己各种折腾电脑,重装系统,到自己工作,才开始关注CPU,i3、i5和i7,甚至最近2020年新款的mac pro的i9处理器,从单核到双核到4核8线程等等。
计算机飞速发展,现在一台普通的笔记本甚至智能手机都能媲美20世纪的服务器了。【PK图,后续添加】
大学时代,我在i3配置的笔记本上用C#实现了一个windows tcp server,一开始只能支持几百个连接(客户端启动和退出一频繁,服务就崩溃),然后使用了IOCP的技术,实现了几千个连接的支持(受限于笔记本)。直到工作,任意找一台台式机,用Linux写一个epoll服务端,就可以实现上万的连接支持,所以现在C10K(单机支持10*1000个连接)已经不在是问题了。
C10K
简单来说,C10K就是指单机如何支持1万个(10*1000)连接,主要是在互联网的早期,Web服务器在硬件有限的情况下,如何能支持更高的并发(众所周知HTTP协议是基于TCP之上的,所以需要建立连接)。
虽然目前服务器性能越来越高,但是如果方法不对(Linux下如果使用select I/O复用模型,则默认最大只能支持1024个连接,更多就需要修改内核并重新编译了),也是做不到1万个连接的支持的。
左耳朵耗子大叔的专栏里面有提到,提出这个问题的人叫丹·凯格尔(Dan Kegel),目前在 Google 任职。可以阅读一下这篇文章了解下这个问题:The C10K problem(翻译版)
这个问题的本质,引用左耳朵耗子大叔的话来解释:
C10K 问题本质上是操作系统处理大并发请求的问题。对于 Web 时代的操作系统而言,客户端过来的大量的并发请求,需要创建相应的服务进程或线程。这些进程或线程多了,导致数据拷贝频繁(缓存 I/O、内核将数据拷贝到用户进程空间、阻塞), 进程 / 线程上下文切换消耗大,从而导致资源被耗尽而崩溃。这就是 C10K 问题的本质。
了解这个问题,并了解操作系统是如何通过多路复用的技术来解决这个问题的,有助于你了解各种 I/O 和异步模型,这对于你未来的编程和架构能力是相当重要的。
C10M
更近一步,单机如何支持千万级(10*1000*1000)连接可以看一下这篇文章:The Secret To 10 Million Concurrent Connections -The Kernel Is The Problem, Not The Solution
解决之道
高性能I/O
理论部分内容太多,可以跳转到《第10章 高性能I/O》一节详细了解。
总体而言,要解决C10K问题,单机支持1万连接在当下是没有任何挑战的,只需要使用Epoll I/O复用即可。主流的网络库都能轻松支持:
- C++部分:libevent、asio、muduo和evpp等网络库,都可以轻松做到。
- Java部分:有Netty和Mina框架,其中netty在国内颇受欢迎,感兴趣的可以通过《Netty权威指南 第2版》更深入的了解,著名的RocketMQ、Dubbo和Kafka等底层通信都是基于Netty实现的。
特别需要注意的是,使用epoll也有三种不同的方案,单Reactor单线程、单Reactor多线程和主从Reactor多线程,读者应区分他们的应用场景,选择适合自己的模型,这三种模型在《第10章 高性能I/O:2种设计模式》一节中有详细介绍。上面列举的各种网络库,应该都有开放接口来设置,比如muduo库中 TcpServer::setThreadNum() 就可以设置使用哪种模型,注意线程数应当和CPU数量保持一致。
/// Set the number of threads for handling input.
///
/// Always accepts new connection in loop's thread.
/// Must be called before @c start
/// @param numThreads
/// - 0 means all I/O in loop's thread, no thread will created.
/// this is the default value.
/// - 1 means all I/O in another thread.
/// - N means a thread pool with N threads, new connections
/// are assigned on a round-robin basis.
void setThreadNum(int numThreads);
其他细节
除了上面提到的高性能I/O之外,还有诸多细节可以优化我们的性能,这里进行简单的罗列,详细介绍和实战后期再补充:
- 零拷贝:这里说的零拷贝并不是指操作系统层面用户空间和内核空间通过sendfile系统调用实现的零拷贝,而是指用户空间的应用程序内,应该避免无畏的拷贝,使多余的拷贝操作贴近0次,没有多余的复制性能开销。
- 无锁编程:在多线程环境下,为了解决资源竞争的问题,我们通常会使用互斥锁、信号量等机制来进行数据同步。比如在TCP服务器中,对于所有已建立的socket连接,我们通常会使用链表来进行管理。连接建立和关闭对应的是链表的添加和移除。我们还会定时的轮询这个链接,来判断哪些连接已超时。这里就会涉及到竞争的问题,合理使用Epoll的Reactor模型,可以避免这一竞争,从而无锁编程,使性能得到提高,程序死锁卡死等问题得以彻底避免。
- 原子操作:针对bool、int等基本类型,C++11中可以使用 std::atomic 使得在多线程环境下,读写一致。
- 动态数组:在epoll的边缘(ET)触发模式下,需要尽可能一次性从socket缓冲区中读取所有的数据,如果我们预分配的内存空间不够怎么办?这里我们就可以使用动态扩容的机制,当需要的时候在调整接收缓冲区的大小,避免早早分配一块很大的内存造成浪费。
- 循环缓冲区:往往和动态数组同时使用,通过头指针、length和offset标志,来循环的使用一块较大的内存空间,避免频繁的申请和释放内存,造成内存碎片的问题,降低性能。
- 内存池:本质上是预分配一整块大内存,且按照一定的大小进行逻辑划分,需要的时候就从里面取,用完了就还,从而达到内存的重复使用,能显著减少内存碎片和提高性能。C++ STL中的容器,底层内存分配算法就是基于内存池技术实现的。
实现
假设我们要开发一个简单版IM服务器,他具备以下特性:
- 在2C4G的机器上,可以同时支持50,000个以上的TCP连接。
- 具备简单的认证功能:客户端连接上来后,必须进行账号和密码认证,才能进行后续的聊天。这里为了简单起见,账号密码硬编码到了本地。
- Echo聊天功能:认证完成后,客户端发送任何的文字,服务器都将回复同样的文字,就像有一个人在和你聊天一样,只不过比较傻。
- 简单版多端消息同步:现在主流的IM,都支持手机和PC软件同时登录,并且手机发了一个消息后,PC上能同步显示,我们这里也希望能实现这个功能。
别看只有这么几个功能,大多数的IM服务里面都有这么一个角色,它是服务器程序和客户端通信的桥梁,通常称之为网关(TCP)。
数据模型
因为用户可能在不同的设备上同时登录,故我们需要抽象出一个User实体,接下来我们看一下他们的关系和主要的接口。
User-Connection模型
Domain-User-Connection模型
C++实现
Go实现
Java实现
改进
万级别的架构:单机
十万级别的架构:多机
百万级别的架构:集群
万到百万的挑战
三高:高性能、高可用、高并发
高可用技术
高并发技术
- 并发编程
- 协程
- 无锁编程
- 缓存技术
- ……