PrivateRookie / ws-tool

High perform & easy to use websocket client/server
Apache License 2.0
193 stars 23 forks source link

impl Clone for Sender #45

Open lithbitren opened 3 weeks ago

lithbitren commented 3 weeks ago

一般通道的接收都是先反序列化,再根据反序列化的结果再match分发处理,如果Clone了,可能对哪个接收者Receiver会收到消息不一定有直观的预期,所以接收者的对于Clone的需求不太大。

但发送者Sender(代指ws-tool内ws相关的所有发送者)大多数时候都有分享到不同线程的需求。比如说推送通知,就有可能来自于不同系统的推送,也有可能来自其他客户端的推送。如果不能把Sender分享到不同线程的话,实现此类功能就很麻烦。

一般而言会考虑两种方式,一种是通道,一种是加锁: 标准库通道、crossbeam-channel、tokio通道、async-channel,都可以实现Sender的跨线程克隆; 标准库锁、parking_lot、tokio异步锁、fast-async-mutex也都可以克隆;

根据测试,同环境里原则上加锁最快(rustcc论坛里有发过测试结果),所以我试了ws-tool(主要是Frame相关的类型),同步环境下用parking_lot::mutex加锁(我是在ubuntu下测试的,window下可能标准库mutex更快),异步环境下用fast-async-mutex加锁,然后放进一个可分享的map里进行全局调度,可以成功运行。

Sender加锁后在执行send固然能够实现多线程分享,但却不是最佳实践,锁的粒度理论上还能够更细,最好细到write/write_all/write_vectored这个级别,但我看了FrameWriteState::send的源码,发现里面部分的write行为是拆分开来的,在单线程或独占的状态下直接write没问题,但不确定锁的粒度进一步细化后,导致write乱序是否会影响功能实现。

建议: 一、如有必要的话,应把同一个send/async_send里的写行为进行合并; 二、增加一个ShardedSender的类型,内建类型是Arc<Mutex<Sender>>,普通Sender需要实现Clone/Into变成ShardedSender,ShardedSender克隆还是本类型,但Arc的引用计数为1时,可以into成Sender(不怎么用得到但也算保持类型可逆性)。

lithbitren commented 3 weeks ago

大佬,这么好的项目可别荒废了,已经大几个月每更新了 @PrivateRookie

PrivateRookie commented 3 weeks ago

ws-tool 没实现 sender clone 其实来自我自己的实践经验。在对消息发送/接收延时要求比较多应用场景里,使用专门的线程处理网络io,然后通过channel 或其他方式共享数据是最目前我能看到最合理的架构。在这个架构中因为只有负责网络io的线程持有sender,所以不用对sender实现clone。 至于异步和同步API 统一,我尝试过maybe_async,但我不是太喜欢它的方案,目前rust也有相关rfc在测试中,还在持续观察中。 目前ws-tool 提供的api其实还比较底层,导致一些不必要的细节暴露给用户,我其实更倾向于在目前的api基础上提供WsApp概念,类似uWebsocket里的app或者zmq里提供的pub/sub push/pull 等面向业务的接口,这样用户在使用ws-tool 发送接收数据时可以想标准库中的channel 一样使用tx/rx。 24年我的生活一直有比较大的变动,导致我没太多时间处理个人项目,也许10月底开始我会有时间继续项目开发。

lithbitren commented 3 weeks ago

@PrivateRookie 目前我看到的几个ws库就只有ws-rs的Sender实现了Clone,用的mio::channel。 用通道还是锁都无可厚非,但Clone功能还是有价值的,起码更像channel了。

不过我个人之前测试下来是锁的性能应该比channel要高不少,tokio默认的异步mutex锁性能好像有些问题关于通道在多线程以及多协程下的性能对比测试(加测),另外仿造tokio-mini-redis用async-channel实现并发hashmap的过程都可以验证出大多数场景性能都不如锁。

我一开始也是先试了Clone通道(通道的逻辑跟io肯定更像一些),因为函数参数想模仿ws-tool原生的函数形式(OpCode, &[u8]),可能是姿势不对,总之通道内建的类型和生命周期的声明很复杂,如果想省事就得转成Vec<u8>,但貌似又涉及了数据拷贝,用Bytes也行,不过算是增加了复杂度,因为数据本身就是&[u8]。 锁就比较简单,找到了Sender相关的类型再包一层Mutex就可以了,就像这样Mutex<AsyncFrameSend<WriteHalf<TcpStream>>>,当然如果锁能够在更内层包裹,粒度肯定能更细一些,以后整理完逻辑后可以考虑起个别名暴露在顶层命名空间。至于通道也不是全无优势,可以比较简单的设置消息容量,锁的话还得另搞信号量来约束。

不管是用锁还是通道,write行为还是原子化更好些,比如这几行: https://github.com/PrivateRookie/ws-tool/blob/c6b1df068565824027db3e1b4458fb2b12b7cc70/src/codec/frame/non_blocking.rs#L157 https://github.com/PrivateRookie/ws-tool/blob/c6b1df068565824027db3e1b4458fb2b12b7cc70/src/codec/frame/non_blocking.rs#L183-L189 最后可能未发送的remain部分其实也可以合并进slices,with_capacity再加1就行了,这样一次性write_vectored保持操作原子性,不管是锁和通道都可以细到这个粒度级别,不然在共享Sender的时候有可能会被其他线程的Sender中间发一手,导致数据乱序。

ps:关于send的代码一点小小的探讨,比如: https://github.com/PrivateRookie/ws-tool/blob/c6b1df068565824027db3e1b4458fb2b12b7cc70/src/codec/frame/non_blocking.rs#L120-L121 parts好像没必要collect成Vec,直接用迭代器方式定义应该也行,total应该也可以直接算出来。虽然分支里最多有两次用到迭代器,但感觉好像其实可以合并进一次迭代里。不过问题也不大,一点点小小的开销而已。

sync/async的API统一与否我个人没啥建议,不过docs.rs/ws-tool/latest看不到async相关的API倒是个问题。

另外,祝大佬生活顺利,早日回归!