Seastar的来源
说到Seastar,不得不说Scylla。Scylla是2015年9月开源,由大神KVM之父Avi Kivity创建的NoSQL数据库,接口协议完全兼容Cassandra,但性能号称快了10倍:每节点1 millionIOPS。Scylla完全基于Seastar库,由C++改写的Cassandra。
所以Scylla的惊鸿面世也带来了大家对于Seastar的瞩目。实际上,Scylla只是Seastar的一个应用,其他应用如Pedis也佐证了Seastar是整个应用性能提升的基石。下图一和图二为Scylla官方给出的测试数据:
那疑问来了,凭什么只用C++改写,就能带来如此巨大的性能差异,莫非C++带来了神秘的魔法功效。可JAVA之类的语言不服,凭什么啊()。这里抛开语言之间的差异不谈,Seastar采用了一套新的编程模式,从而最大化去利用硬件的性能。
如下图所示,从总体架构而言,Seastar是一个完全分片(share-nothing)的设计:每个logic core 一个thread,每个core有自己的资源:CPU, network, disk I/O, memory。多个core之间没有资源的竞争,随着core数量的增加,扩展性和性能也随之提升。
对于多个core之间的通信,采用point-to-point queue发送和接受异步消息。所有core之间没有数据共享,没有锁,没有cache lines频繁的miss。与此同时,Seastar也是一个异步编程框架,下文会进一步深入展开。
Seastar简介
概括来说,Seastar 是一个开源,基于c++ 11/14 feature,支持高并发和低延迟的异步编程高性能库。要想深入学习Seastar,需要掌握新的C++ features,这些features包括:
Auto/decltype
Tuple
Variadic Template可变参数的模板
Move copy/Assignment 移动拷贝/移动赋值
Metaprogramming 模板元编程
Lambda函数
Smart pointers智能指针
Future/promise
这些新的特性很复杂,背后都有深厚的学术和业界工程实践的积累,学习起来有一定难度。实际上Seastar代码里充斥着几乎所有新的C++ features和新的c++库中的APIs (包括STD 和boost库)。而且为了自己的需要,Seastar扩展了C++标准中的部分特性。
例如STD中的future/promise就不能满足Seastar复杂的异步编程的需要,为此,Seastar实现了广泛的future/promise接口。同时,Seastar重新实现了一些有别于标准库中的数据结构,因为像thread safe这样的要求是无需考虑的。所以,阅读Seastar这样的匠心之作肯定能带来别样的收获。
异步编程
1)为什么需要异步编程?
随着硬件的高速发展(SSD, 10G/40G network, NUMA),软件开发人员的技能还停留在传统的开发模式上,这样写出来的程序就限制了新硬件性能的发挥:
当硬件发展到了一定阶段,单核的性能很难再飞速增长
多核的数量在增加,但应用很难有效利用他们
锁的开销很大。在传统的多进程/线程的编程中,锁是保证数据安全的重要手段。由于资源(file, memory)的竞争, 进程/线程不得不阻塞等待。据实验测试,一个高并发的应用,20%~70%的时间可能耗在无谓的锁等待上。
数据分配在一个核上,可能复制和使用在别的核上例如一个网卡的中断程序运行在一个core上,而后续的数据包的处理可能迁移到别的core上,这样CPU的cache line频繁的miss,造成性能的penalty。
用户态/内核态,进程线程/中断上下文切换的开销
2)异步编程的起源
同步和异步区别是什么?同步意味着一个操作必须等待它调用的其他操作完成后才能继续进行下一步的操作。而异步则是无需等待,当一个操作调用一个会阻塞的操作时,它会接着做别的事情,等那个阻塞的操作完成时,会发一个event通知它,那么它接着处理这个阻塞的操作。
我们看看传统的一个network server 是如何处理并发的网络连接:当一个client连接到来时,一个新的process或者thread被fork 出来,即使某个连接调用一个阻塞(blocking)的系统调用(syscall)也没有关系,因为可以有的其他process或者thread来处理新的连接。为什么说这种编程是同步的呢?因为代码的执行就如程序员所写一样是线性的,严格按照一行一行代码的逻辑顺序执行。
尽管从操作系统角度来说,本质上系统是并发执行的:当一个process/thread 调用一个阻塞的系统调用时,OS会自动切换到另一个合适的process/thread去执行。同步编程是有开销的:首先,fork一个新的process或者thread很慢,再加上如果大量的操作被阻塞,随之发生的频繁进程/线程上下文切换的代价也很大。除此之外,每个process/thread需要有自己独立的栈空间,如果系统中process/thread很多,栈的内存开销也会很大。
最近一些年,异步编程开始流行起来。各种语言从Python,JaveScript,Go到C++纷纷开始支持异步编程。一个server是异步的,那么它本质上是事件驱动的(event-driven)。通常只有一个thread,这个thread就是一个迭代循环执行。每次迭代它都要轮询(poll)有没有新的事件(event)要处理,如果有,可以调用相应的已经注册好的具体事件处理函数。
事件的处理是run-to-completion,也即意味着main thread不会处理下一个even,除非上一个event已经处理完成。这些event可以对应网络socket连接,存储disk I/O和计时器等。这样整个系统没有thread休眠和锁造成的休眠或者忙等待,所有的组成构件都在不停地运转,系统性能达到最优。当然,对于Seastar来说,是每个core一个这样thread,它称之为engine。
3)异步编程的挑战
复杂性:代码不再像之前同步编程时可读性那么好。许许多多从简单到复杂的回调函数嵌入(递归嵌入)到各种代码分支中,大部分时候,程序员阅读所见的代码并不一定能在逻辑上顺序执行到,执行的时机取决于一些状态值。
要妥善安排这些代码之间的关系(event 和event hander)并非易事。为了减少这种异步带来的复杂性,Seastar实现了future/promise对象,用来管理这些异步操作。这样基于回调函数的编程就变成了基于future/promise的编程。
非阻塞模式:因为每个core上只有一个thread在运行,这个thread不能被阻塞。所以它不能直接或者间接调用任何可能阻塞的系统调用,也不能调用锁接口以防止死锁。
Seastar异步编程基石
Future
Future代表一个值可能未定的计算结果,这个结果可能现在不能马上得到,需要等待到将来某个时间点。这种future可以是网络传输的一个缓存,定时器的到期,磁盘写的完成等,它可以是任意一个需要等待的结果。一般我们把一个异步函数的返回值作为一个future,这个future最终向调用者提供结果。
例如我们可以用future read来表示读取磁盘文件的结果,这个结果是一个int值,这个read函数没有任何等待,立马返回给我们一个future. 调用者调用future.available()检查值是否可用,一旦可用,就用future.get获取相应的值。
Promise
Promise顾名思义承诺,代表一个异步函数,这个异步函数返回future,并承诺在将来的某个时间点给future赋值。接口promise.get_Future获取对应的future,promise.set_value(T)给对应的future赋值。
Continuation
Continuation代表一段计算,最常用的就是Lambda函数。这些continuations通过future的then函数绑定到future上,then函数的输入参数就是绑定的future对象。当future 的值available时,这些绑定的continuations就会自动执行。
Continuation最大的威力在于:一个continuation可以返回一个新的future,这个future又可以绑定新的continuation。这样就可以实现异步操作级联执行future.then().then().then()…。
高阶接口
Seastar在f-p-c基础上还实现了更高级的接口:
异步操作的并行执行parallel_for_each
异步操作的循环执行repeat
同步等待异步操作的执行when_all
对于map reduce支持
Semaphore,gate和pipe等接口。
从这里可以看出,Seastar是一个完备的支持异步编程的框架。
Seastar架构
Seastar是一个基于分片的异步编程框架: 它能够实现复杂的服务器逻辑,保证网络和存储操作,多核之间操作的异步性,以达到高性能和低延迟的目标。图四可以清楚地看出Seastar相对于传统数据栈的优势。
内存shard
Seastar运行后会保留(--reserve-memory)一小部分内存给操作系统或者预留(-m)一定数量的内存给自己。
这样,Seastar对分配给自己的物理内存也进行了分片(shard),每个core有自己的内存空间,有自己的memory allocator(log-structured)对内存区域进行分配和释放管理,无需考虑thread safe和内存碎片化(定期compact,移动object,合并memory holes)。下图对比了基于JVM和Seastar的内存管理:
网络shard
所有的网络连接在cores之间分片(shard),每个core只负责处理自己那部分数据连接。
但是对于Posix network stack,尽管在Seastar这一层是shar-nothing设计,由于Seastar需要和下方OS network stack进行交互,这样就可能有锁,原子操作,CPU 缓存的miss,性能不可避免地受到损失。所以要想获得最佳性能,推荐配置Seastar native network stack。
用户态task调度
下图对比了Seastar任务调度和传统线程调度:
每个core上都有一个task scheduler,相对于内核中的thread,每个task都是一个轻量级的任务。Seastar中有两种类型task:
Non-threaded context task 这种task一般很短,没有自己的栈,主要由Lambda函数组成,event-loop 主线程循环调度task队列中的Lambda函数。当然如果这种task不能立即执行,是需要内存空间来保存相应的状态,不过比起thread栈来说,开销很小。
Threaded context task 这种task的实现实际上就是用户态协程:它们有自己的栈,只会主动让出(yield) CPU,用setjmp/longjmp进行用户态的上下文切换,有相应的调度policy(例如基于时间片的调度去保证公平性)。
所有这些用户态task不能调用blocking系统调用,因为整个core只有一个系统thread。有时候又不得不调用blocking系统调用,那只能另起一个Posixthread。所以大部分时间,整个core只有一个Posix thread在运行,有时候会是两个。
用户态disk I/O的调度
Seastar利用操作系统libaio提供的io_submit去提交磁盘操作和io_getevent来收集操作结果,从而实现磁盘I/O操作的异步性。但Linux对于aio支持的并不是很好,并不是所有文件系统都支持aio,即使有的支持,也有很多问题。
最新的xfs对于aio支持的很好,所以对于disk IO,只推荐采用xfs。由于在内核中,从文件系统(file system)到块设备层(block level)再到具体的存储设备层,每个层都有I/O队列,对I/O进行了自己的管理。一旦storage I/O出现拥塞,不太容易判断哪层出现问题,也不好采取措施进行调控。
因此,Seastar在用户态实现了I/O scheduler,对磁盘I/O进行精确的分级控制和调优。Seastar有自己的I/O queue来缓存I/O,并实现了各种I/O priority class,从而保证各种I/O调度的公平性。下图示意了Seastar实现的用户态I/O调度器:
用户态原生网络栈(Native network stack)
下图描绘了Seastar的网络栈,即包含Posix stack,也包含原生网络栈:
除了支持常规的Posix network stack,Seastar还支持基于DPDK的native network stack。大家知道,DPDK是Intel推出的一个高性能用户态网络管理包,其核心思想和Seastar是一致的:用户态轮询(poll)模式的网卡驱动,没有中断,没有上下文切换,没有内存拷贝,无锁,share-nothing,自己的内存管理,利用NUMA等等。
DPDK主要提供L2数据包处理功能,需要上层应用提供L3及以上的网络管理。Seastar实现了TCP/IP协议:每个core绑定到物理网卡的一个接受队列和发送队列,这样所有的数据连接也被分片(shard),每个core自始至终只负责自己那部分数据连接。对于native network stack,没有syscall调用,没有多余的数据复制,没有锁,性能当然最好。
领取专属 10元无门槛券
私享最新 技术干货