59

一个 Socket 能否被多线程写入

 6 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzIxMjAzMDA1MQ%3D%3D&%3Bmid=2648945984&%3Bidx=1&%3Bsn=28e5784d2fad05e06c5ef3a685e63ee4&%3Bchksm=8f5b524cb82cdb5addbd8814c1390b5961d8322fd991d0ec2e56b2bbb1aa2b407db09ba
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

一个Socket能否被多线程写入

Original fireflyc 写程序的康德 2017-12-20 22:00 Posted on

Image

这段Python代码会连接到服务器端,然后启动两个A、B线程。A不停写入字符“a”到Socket,一次写入32k;B每隔1秒钟写入字符“b”到Socket,每次写入10字节。

A、B两个线程共享了同一个Socket,每次写入都是“完整的数据包”,Python的sendall方法会保证整个数据块完整的写入到Socket中。

问题:服务器端收到的是什么?

这是一段服务器端代码,它接受客户端请求读取第一个字符,如果是“a”则尝试读取后续的32767(32768-1)字节;如果是“b”则读取后续的9(10-1)字节。每次读取都是“完整的”,这一点是通过loop_read不断循环读取实现的。

客户端代码通过sendall方法能保证完整的数据交给内核,那么服务器端收到的数据可能是32k的a或者10bytes的b。

找两台服务器分别运行服务器端和客户端(我的环境是2vCPU,1G内存)。大概10-30秒左右,服务器端输出错误——发现“不完整的数据包”。(输出太大,我截取其中一部分)

为了验证不是程序bug而是真的会出现“乱入”(囧,我真找不到合适的词),我通过tcpdump -i ens160 -na -vvvv -Xx port 3000 -w test.pcap在服务器端抓取了数据包,然后通过wireshark分析完整的通讯过程。

“b”数据包只出现了一次,在数据包50。通过wireshark我计算出来前49个数据包一共是390800字节,每次a都是32768一组,那么前49个数据包发送了390800/32768.0= 11.9组“a”。注意:这是一个小数,也就是说最后第12块数据应该全是“a”而这10bytes的“b”完全是“乱入”。

先看一下write方法的工作过程(所有的网络写入其实都是这个系统调用)

write函数最终会调用内核中的tcp_sendmsg函数,数据先被复制到tcp buffer中(这是位于内核的一块存储空间,大小是由参数tcp_wmem控制的),然后加上TCP头、加上IP头,丢给物理网卡。物理网卡中有一块发送队列的存储空间用来存放所有待发送数据。这个发送队列比较特殊是“环形”结构(ring),如果数据太多来不及发送会被丢弃掉(与之对应网卡还有“接收队列”,也是ring结构)。

虽然这个函数开始的时候通过lock_sock 上了锁但是它绝对仅仅代表是线程安全而不是无状态一个的函数。

无状态是指,只要输入的参数一样那么得到的结果应该是一致的;而线程安全是指两个线程可以同时访问。所以无状态一定是线程安全的,而反之则未必。

A、B两个线程,其中A每次写入32k,32k可能会被拆分成多次写入(根据buffer剩余空间决定真正能写入多少数据);B每次写入10bytes。如果内存不足(图中的wait_for_sndbufwait_for_memory)只写入一部分数据那么内核会调用sk_stream_wait_memory等待内存,而这个函数里面会释放sk。完整的调用链sk_stream_wait_memory->sk_wait_event->lock_sock

当A写入数据的时候资源不足所以写入不完整于是释放资源,而B此时有机会被执行后刚好资源得到释放,于是写入成功;而A再次被执行的时候继续写入未完成的数据时,B已经“乱入”成功

当分析出结果的时候我的表情

如果你去做实验的话可能无法重现我上面的错误,因为这个问题跟语言有关

首先,tcp_sendmsg不承诺“无状态”(或者叫原子性),这比较容易理解——毕竟send buffer满了,线程等待内存空间此时不应该继续占着“socket”(文件描述符)。内核要保证进程不被饿死,让资源尽可能的最大化的发挥作用。

那么做出“无状态”承诺的只能是应用程序,除了C语言之外其他的编程语言都不是直接调用systemcall,所以势必对socket写入函数做各种合理封装。

经过我实验发现Python、C语言会出现问题,而Java和Golang不会出现问题。以Java为例(SocketOutputStream.java):

这个函数没有返回值,它先对文件描述符(FD)加锁,然后一直尝试写入直到写入完len长度的数据为止。

Golang也有类似的实现,而Python中它的实现是这样的

看到了吗?虽然一直在调用write(system call)写入,但是并没有对文件描述符加锁,所以Python的实现不承诺“无状态”。

而C语言的实现基本上和Python的实现一样。

有三种办法解决这个问题

  1. 统一由一个Writer线程负责写入,其他线程通过Queue发送数据给Writer;

  2. 每个线程各自启动一个TCP连接,不考虑连接复用(其实开销真不大);

  3. 参考Java或者Golang的实现,为write加上锁;

欢迎关注公众账号了解更多信息“写程序的康德——思考、批判、理性”

Image

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK