从券商SDK消息到达,开始运行到下单,再到定位(下单请求)整个过程非常冗余,以下仅先探索前面队列驱动及优化部分。
我们如下绘制流程图:
QuoteReader(行情读取器/对应每个券商) --> QuoteService(行情服务)
--> RouteTable(关注股票路由表) ,如果股票不感兴趣,就不处理丢弃数据包,否则加入_QuoteHandler 到 handler_ptrs(行情处理器列表)
行情服务器会创建多个线程,并且绑定线程到对应的核心上面,分派股票到 QuoteHandler 是通过模数法。
公式:
(next_index_ + 1) % handler_ptrs_.size()
其实你知道的,这个是错误的,正确的做法命名是:(next_index_) % handler_ptrs_.size(),来获得派发的上下文索引。
并且这里还存在致命的问题,因为这种队列,会存在多核利用效率低的问题,如:某个股票(或一组能 mod 在这个索引上的 QuoteHandler),我们设想一下这种条件是非常容易达成的,那么 QuoteHandler 会直接导致致命的性能洪塞问题,打爆驱动这个 QuoteHandler 的线程,而且这个线程还是个绑核的,被洪水冲压的负担是极重的。
这个设计简直非常不专业,正确的做法是像我们创建一个事件循环队列,就像 boost::asio、ev_event/uv_event 事件队列循环一样,可以同时多个线程去驱动它,但我们实现一个 strand 串行化的机制。
首先要知道,把它实现成无锁队列,并确保多个线程,可以进行驱动,确保所有的线程都可以有效的执行工作,是很有意义跟价值的,并且这并不难以实现它。
我们设计一个无锁化的 strand 串行队列,结合 SpinLock,自选锁,可以以非常低廉的成本来完成,高性能的队列数据交换传输。
而且在我的新设想当中,已经不会再存在前面这么多冗余架构,这是没有意义的,额外增加性能开销成本,从一个队列传给另外一个队列,然后多重队列开销,要知道这东西最好的就是一个全局并发队列是性能最好的解决方案,即便是面对高频也是毫无问题,因为自选锁的开销很低,这里的队列,我们直接用 std::list 就可以,但我们需要自定义 std::allocator 内存分配器
CAS原子队列只适合多个线程写,单个线程读取的情况,SPSC队列只适合一个线程写,一个线程读,但我们需要的是多线程读、多线程写的高性能队列,那么上面这样的队列就可以。
这种队列是一个大结构,行情数据过来(券商SDK行情消息是分派到不同线程回调过来的),它可以直接触发我们的队列管理逻辑这部分,因为大家都知道合理的设计,根本不存在什么性能开销,它不可能一秒钟塞入一个亿的数据,我们结合CAS自旋锁来实现这样一个管理结构。
message_on_order、message_on_trade、message_on_snap 这三类消息,我们不能保证消息的顺序性,但我们可以同个类型的消息是连续性的,所以,这部分我们如果额外增加一层队列,在循环这个队列的开销是比较大的,但我们设计成这样,开销只会比这个更低更好。
即:
自旋锁 {
主队列 [股票ID]->post( 消息指针 );
} // 临界区尾部
主队列 = std::unordered_map<股票ID(UINT32类型).....无锁串行队列>
看上面这样的结构,我们可以得知,是绝对不可能产生什么多核性能问题的。
但这里还没完,我们需要实现一个 多线程驱动器上下文,类似 ev_event、boost::asio::io_context 调度器这样的玩意,那么,要怎么来高性能多线程驱动这个,主队列容器呢?
首先,我们不能弱智的轮询法,这个股票ID数量可能高达三千只,这样直接轮训代价过于昂贵,我们假设现在为主队列驱动四个线程(绑定四个CPU核心)意味着同时可以四个核心都在处理数据。
那么,这里可以通过一个取巧的办法。
我们可以创建一个跟主队列相同长度的链表结构,即拥有相同长度的链表节点,但是我们不要去分配更多的,也不要用了就释放(因为这个场景是程序跑在实盘环境下,要退出直接关闭程序,直接对象池化计数,因为交易所下午三点收盘),那么什么时候分配 NODE?
每次发现主队列没有指定股票ID的串行队列时,那么就可以认为是需要分配新的串行队列、跟对应的NODE,这个NODE其实可以直接在串行队列当中分配,弄成私有变量,跟主队列这边管理代码声明为友元好类,它们本来就是一个整体。
只要队列里面添加上了数据,那么就在串行队列上面,增加一个原子计数,或者手动管理内存屏障来同步计数。
这个计数的目的是用来,表示信号量的,即当前队列有事件,就需要被执行处理,直到这个信号没有,就像是共享指针一样,是一个调度执行所需要的引用计数。
只要我们插入消息,等效于一个事件信息时,这个计数就要添加1,如果这个计数从0到1(若为原子处理,是可以获得++之前的原有值)的,我们判断如果为0,那么就意味着这个需要插入到主队列当中的调度链表中,而不是每次enqueue(post)都是无条件插入,这是错误不对的。
这样子,主队列的多线程那边驱动器是不是就知道了,哪些要去遍历驱动,但这里有一个很有意思的并发处理设计细节,每个线程一次只能争用一次主队列CAS自旋锁,并把链表头部的元素弹出,同时检查,如果本次减少了一个计数,是否等于=0,如果为0则表示这个不需要添加到链表的尾部,注意:非0需要添加到链表尾部。
每个线程通过这样的方式,从链表中循环获取主队列当中的事件处理对象。
实际上这里分了两个步骤。
串行队列的事件,是用来告诉主队列,我能不能被调用,避免主队列那边线程遍历三千个股票,减少性能损耗,只去处理需要执行逻辑的地方。
串行队列这边实现更有意思,post 仍然是需要实现为自旋线程安全的,但会增加一个CAS当前执行线程标质,0表示没有现成在执行,非0=线程ID,表示这个线程它正在执行流程,因为我们需要多核串化流程,让在空间时序上面要保持顺序的,
但必须被多个线程多个CPU核心同时驱动处理,又要尽力保证没有性能损耗,尽可能减少队列造成的性能损耗。上面提到了,单核单线程,在特定情况下会导致延迟增大,因为突然来了大量消息,被派发到这个线程给洪塞堵住了,导致了大量的延迟及计算负担,没有合理利用硬件资源。
但需要注意:必须确保cas锁的最小持有粒度,即在我们获得当前需要处理的串行队列指针后,不要在上锁了,而是需要释放出来资源,让其它线程可以去争用下一个串行队列。
好的上述流程跑完,我们就可以执行串行队列的 do_events 函数,这个函数只需要两个分支部分。
一个通过线程 thread_local 预存的当前线程ID,来跟CAS原子变量进行 原子比较交换,而不进行自旋,如果失败表示当前队列正在被其它人争用,它就不要去处理直接跳出,否则就进入开始处理当前串行队列当中插入的消息并派发到不同的消息处理函数当中,首先这里需要明确一点,
它自己串行队列有一个写的原子锁(CAS自旋)这个是为了跟上面 Enqueue(post) 函数成对临界区代码保护的,所以,你只能弹出队列上锁,并且弹出一个就得释放,处理完毕后,重新争用写原子锁尝试弹出,如果失败就把当前标记CAS线程的原子变量设置为0,即可,
这样我们就可以尽可能的利用更多的CPU,让整个系统调度负载更均衡,效率更好更棒更强,并且我们可以结合C++ 17有栈协程,尽力让这四个线程不会让出所有CPU给操作系统,当然这四个线程会被绑定CPU核心亲和性,并且线程优先级最高,进程改成RT实时模式,最大化独占资源,实现更为理想的高频高并发高时空线性处理。
注意;这里是现有队列派发驱动,跟新型队列派发思路的一种设想。