本文转载自:https://www.cnblogs.com/whuwzp/p/thread-safety-socket-send.html
1. 概览
1.1 起因
自己写的项目里,为了保证连接不中断,我起一个线程专门发送心跳包保持连接,那这个线程在send发送数据时,可能会与主线程中的send冲突,因此我就想探讨一下socket api是否具有线程安全性。网上很多说法,但多是推测,于是我结合man pages、StackOverflow和大佬们的博客等资料,做了简单的实验测试一下,用事实说话。
1.2 探究的主要问题和结论预告
以下问题是主要关注Linux tcp,所有结论都是Linux环境下的, 但是也会有UDP, Windows C++,C++ boost库和java语言等StackOverflow上相同问题下的资料和链接。
当两个线程或进程同时对同一socket进行send(write)操作时,会不会出问题,例如两个线程的内容会不会交错, tcp和udp是否情况相同
tcp情况下会有问题:当某线程发送的数据量过大时(实验测试为一次性发送32KB),两个线程发送的内容出现了交错的情况;当数据量比较小时(测试为4B),内容没有交错。(我并没有过多关注数据量大小的设置,总之可能出现交错,所以尽量避免这样使用)
tcp还会有一个问题(根据知乎陈硕大佬的回答总结的):如果出现实际发送值小于请求值(short write)的情况,此时该线程返回,按照一般的设计逻辑会采用循环send直到发送值总和等于请求值,那么这样一来,该线程后续发送的数据也有可能会与其他线程的数据交错。(关于这个问题详见4.5小节,因为我实验测试没有short write情况,但是这个情况确实值得考虑)(后来我又测试了4000字节左右的数据发送,发现不仅出现了交错,还有数据不完整的情况,可能是数据被覆盖,详见实验部分)
udp未测试,但是所有收集的资料都表示,在UDP情况下不存在这种问题。
如果考虑在应用层为send加锁是否可以解决上述问题
答案是不能的,不加锁
send或write是否线程安全
线程安全性:从实验结果上看,不具备线程安全
关于socket的线程安全, 也有人认为是具备的,他们的意思是:可以一个线程发送,另一个线程同时接收,就叫线程安全,这个我是知道的,但是这与我想讨论的情况不一样,所以需要明确我说的socket线程安全的定义:两个线程同时发送时的线程安全性。
2. 实验测试
测试环境:VMware station 15,虚拟机Ubuntu server 18.04.4
测试代码地址:https://github.com/whuwzp/linuxc/tree/master/bug/twothread_send
2.1 测试思路
测试思路很简单,一端起两个线程同时循环发送数据(一个线程发一串aaaa,另一个发一串bbbb),另一端接收并打印,看看结果会不会有交错的。
关于内容交错,我并不关心数据达到的顺序,只关心一个大的数据包会不会穿插着另一个数据包,具体来说:两个线程Ta,Tb,有一个socket S,Ta和Tb同时向S发送数据,其中Ta的要发送数据很大(需要分片),由Pa1和Pa2两部分组成;Tb发送数据较小,为Pb1,那么如果对端收到的是:
Pa1 Pa2 Pb1或者Pb1 Pa1 Pa2:则认为没有交错(Ta和Tb的数据各自都连续到达,只是顺序未知而已)
Pa1 Pb1 Pa2 :则认为有交错。(因为Ta数据包被隔断不连续了)
注意:测试时候某个线程发送的数据包一定要足够大,否则不会出现交错的现象,我之前就是发送太小,误以为不会交错,原因在第三节的分析中会讲到。
2.2 测试代码
测试代码地址:https://github.com/whuwzp/linuxc/tree/master/bug/twothread_send
server.cpp, 以下简要展示关键代码:
typedef struct {//为了更好地展示,我发送的是结构体,用index标记是第几个a或者b
char buf;
int index;
} myint;
//Ta
void * athread(void *) {
for (i = 0; i < 4 * 1000; i++) {
buf[i].buf = 'a';
buf[i].index = i;//标记是第多少个'a'
}
//循环发送, 每次发送4000个结构体数据
while (true) ret = write(fd_client, buf, 1000 * 4 * sizeof(myint));
}
//Tb
void *bthread(void *) {
for (i = 0; i < 100; i++) {
buf[i].buf = 'b';
buf[i].index = i;//标记是第多少个'b'
}
while (true) {
ret = 0;
for (int i = 0; i < 10; ++i) {//循环发送10次100个结构体
ret += write(fd_client, buf, 100*sizeof(myint));
}
sleep(1);
}
}
client.cpp
while (true) {
memset(buf, 0, 4 * 1000 * sizeof(myint));
ret = (int)read(fd_client, buf, 4 * 1000 * sizeof(myint));
for (i = 0; i < ret/sizeof(myint); i++) {
//因为4000个a打印起来很多,所以只打印b,或者接收数据不完全的情况
if (buf[i].buf == 'b' || ret != 4 * 1000 * sizeof(myint)) {
printf("ret=%d\n", ret);
for (i = 0; i < ret/sizeof(myint); i++){
printf("%c, %d\n", buf[i].buf, buf[i].index);//打印其索引
break;
}
}
}
}
运行:
./server
./client > result.txt
2.3 实验结果
输出结果result.txt
# 此处省略a0~a3391
a, 3389
a, 3390
a, 3391
# 此处是重点, 内容交错的起点
b, 0
b, 1
# 此处省略b0~b99, 10次循环
b, 98
b, 99
a, 3392
a, 3393
# 此处省略a3394~a3999,以及b的后续
通过结果可以看出:
先接收了Ta发送的0~3391数据包
再接收Tb接收的10次0~100数据包
最后再又接收了Ta的3392~3999数据包
也就是说, Ta与Tb的数据包发生了交错。
更新:在知乎大神洋耗子的提示下后来又测试了4000字节(page size左右)的发送,发现不仅有内容交错,还有接收端无法接收完整数据的情况,输出结果result_4kb.txt:
# 这就是开头的, 发现没有a0~a7
a, 8
a, 9
个人猜想,可能发生了数据覆盖,因为只要发送了,tcp一定可以保证接收,所以不可能是丢包,希望了解这个的大佬可以解释一下。
Linux下可以用getconf PAGESIZE命令获取page size
3. 原因探究和实验结果分析
3.1 原子操作性和线程安全性
首先这两个概念是有区别的(详见stackoverflow上的讨论):
原子性:意味着从一个操作开始的所有字节一起结束,而不会与其他I / O操作交织(这是ibm文章摘自IEEE Std 1003.1-2001 System Interfaces volume中的定义)。也有的人说:要么100%操作成功,要么失败了就把状态回滚到操作之前,或者不可分割。百度百科-原子操作。
线程安全:多线程同时进行一个操作而不会相互影响。原子操作是实现线程安全的一种方式。(摘自stackoverflow上的讨论
3.2 socket api是否线程安全与c/c++语言本身无关。
socket不是c/c++语言标准, 所以它的线程安全依赖具体的实现. 每个系统不同, 有的系统实现socket时用了锁来保护内部数据结构, 有的系统可能没有, 所以得看系统具体实现.
Sockets are not part of C++ Standard so it depends on implementation.
Generally they are not thread safe since send is not an atomic
operation. Check this discussion for additional information. EDIT: OS
could have or couldn’t have internal lock for protecting internal
structures. It depends on implementation. So you should not count on
it. 摘自:
https://stackoverflow.com/questions/2354417/c-socket-api-is-thread-safe
3.3 可能原因分析
以下内容参考:https://quark.tistory.com/m/235 (这个链接不是原文章的链接,原文链接已经失效了,这个是韩国网站上转载的),如果访问慢,可以看我转载的:https://www.cnblogs.com/whuwzp/articles/thread_safe.html。
简单解释一下:
send函数最终调用内核的tcp_sendmsg()函数。
tcp_sendmsg()函数需要通过lock_sock()获取一个锁,然后开始循环分配缓冲区空间,并把待发送的数据拷贝过去,准备发送
如果待发送的数据特别大(这里回答了为什么发送32kb那么大的数据)导致循环分配空间到某个阶段失败了(部分数据已经成功了),就会最终调用sk_stream_wait_memory()函数等待空间可用,而该函数内部会调用sk_wait_event()去释放锁,然后阻塞等待着。
一旦这个线程释放了锁,那么其他线程的send函数就可能通过lock_sock()竞争到锁,然后发送他们的数据,这样两个线程的数据流就会交错在一起了。
这个解释非常符合实验结果。但是这只是一种可能,我们只能判断一定是释放了锁,且被别的线程竞争到了锁,是不是只有内存分配失败才会导致这样呢?我也不太确定,希望读者一起读第四节,一起探讨。
4. 其他的观点探讨和分析
4.1 原子性的问题
其实到现在我仍然不能确定send、write是否具备原子性,所以这里暂时只是分享一些观点,我个人稍微倾向于相信它不是原子性的,但是也希望大佬多指教:
因为IBM文章的实验和我自己的实验都表明并不是“一起结束的”。因为部分数据已经发送了,另一部分最后才发送。
认为是原子性的观点没有给出实质的论据。
4.1.1 认为POSIX标准下不是原子性的观点和依据
以下摘自IBM文章
the POSIX/SUSv3 standard developers indicate that atomicity for socket
I/O is “unspecified”.Why not atomic?
Using Linux kernel 2.6.11 as a reference, because it’s the latest
kernel processed for easy web cross-referencing at LXRWhen a connected TCP send(), sendto(), or sendmsg() arrives in the
Linux kernel, it eventually comes through tcp_sendmsg(). tcp_sendmsg()
protects itself by acquiring a lock at invocation by calling
lock_sock(). tcp_sendmsg() then loops over the buffers in the iovec,
allocating associated sk_buff’s and cache pages for use in the actual
send. As it does so, it pushes the data out to tcp for actual
transmission. However, if one of those allocation fails (because a
large number of large sends is being processed, for example), it must
wait for memory to become available. It does so by jumping to
wait_for_sndbuf or wait_for_memory, both of which eventually cause a
call to sk_stream_wait_memory(). sk_stream_wait_memory() contains a
code path that calls sk_wait_event(). Finally, sk_wait_event()
contains the call to release_sock().At this point, any one of the threads that were heretofore serialized
at the initial call to lock_sock() in tcp_sendmsg() can proceed.
Memory may either become available, or a small enough send may not
require enough memory to block and may proceed immediately, thus
intermixing data from one call to send() with another.but in the definition of read() in the IEEE Std 1003.1-2001 System
Interfaces volume, it gives the following rationale: “The standard
developers considered adding atomicity requirements…”
4.1.2 认为POSIX标准下是原子性的观点和依据
以下认为send是系统调用, 是原子操作, 不会在内核中有竞争. 但是这个和IBM文章就有矛盾了.IBM文章的分析应该是有竞争的(当多个线程同时send). 虽然他认为是原子的, 但也认为多个线程同时发送也要保证同步问题, 以免内容交错.
A lock is not required; send() is a syscall, it is an atomic operation
with no race conditions in the kernel.For stream sockets (TCP) too,
the send() function is atomic; but there is no concept of distinct
messages or packets, the data treated as a single stream of bytes. So
even though send() is thread-safe, synchronisation is required to
ensure that the bytes from different send calls are merged into the
byte stream in a predictable manner.摘自:
https://stackoverflow.com/questions/1981372/are-parallel-calls-to-send-recv-on-the-same-socket-valid/61246416#61246416
以下的观点也认为是原子的, 但是他说send的调用总是完全成功, 或者完全失败, 但是实际上send是可以返回小于请求值的(也就是小于待发送的数据长度).
Sorry, you’re wrong. Send is 100% an atomic operation. If the data is
too large and won’t fit in the output buffer, then the call will
block. If the call to send is interrupted, then no data is sent and an
error is returned. Calls to send always completely succeed, or
completely fail.摘自:
https://stackoverflow.com/questions/3235424/c-sockets-send-thread-safety
4.2 short write导致即使是原子操作也存在安全问题
如果出现实际发送值小于请求值(short write)的情况,此时该线程返回,按照一般的设计逻辑会采用循环send直到发送值总和等于请求值,那么此时该线程又会和其他线程同时竞争发送,如果不能确保该线程能够持续发送后续的数据,那么就可能会与其他线程的数据交错。此时即使是原子操作的,也是存在安全隐患的。
一般采用的循环发送伪码如下:
while(sendlen<querylen){//循环保证实际发送值等于请求值
sendlen += send(querylen-sendlen);
}
关于用应用层加锁的模式解决问题的思路,可以参见知乎陈硕大佬的回答:
short write: write(2)/send(2) 返回的数字比你要发送的字节数小。
对于 TCP,通常多线程读写同一个 socket 是错误的设计,因为有 short write 的可能。假如你加锁,而又发生 short write,你是不是要一直等到整条消息发送完才解锁(无论阻塞IO还是非阻塞IO)?如果这样,你的临界区长度由对方什么时候接收数据来决定,一个慢的 peer 就把你的程序搞死了。
摘自:https://www.zhihu.com/question/56899596/answer/150926723
4.3 Linux man pages的观点真的错了吗
以下摘自:Linux man pages http://man7.org/linux/man-pages/man2/send.2.html
If space is not available at the sending socket to hold the message to
be transmitted, and the socket file descriptor does not have
O_NONBLOCK set, send() shall block until space is available.When the message does not fit into the send buffer of the socket,
send() normally blocks, unless the socket has been placed in
nonblocking I/O mode. In nonblocking mode it would fail with the error
EAGAIN or EWOULDBLOCK in this case. The select(2) call may be used to
determine when it is possible to send more data.
意思是如果socket缓冲区不能装下待发送的数据,则send函数会阻塞直到内存可用,然后发送。
上一节ibm文章依据实验结果,认为并没有阻塞,从而判断man pages的这一观点是错的。
我的观点上节也说了:但是这只是一种可能,我们只能判断一定是释放了锁,且被别的线程竞争到了锁,是不是只有内存分配失败才会导致这样呢?我也不太确定
4.4 UDP会不会遇到和tcp一样的问题
据所有收集的资料的说法:不会。
因为udp是数据报大小受限(UDP不具备分片能力,或者说不具备分片后,对端正确组包的能力),因此不像tcp发送这么大的数据,他们的操作会很快发送(不会像前面分析的,还会等待内存分配而阻塞),因此“看上去”udp的操作是原子的。
以下是支持这个观点的资料:
Note that datagram sockets (UDP) are connection-less, so the the
datagram packets may be delivered in a sequence that is different from
the sequence in which they were sent (even if all the send calls were
from within a single thread).摘自:https://stackoverflow.com/questions/1981372/are-parallel-calls-to-send-recv-on-the-same-socket-valid/61246416#61246416
Q: can I concurrently have two threads at the same time write (sendto)
on a same file descriptor? How about readfrom and sendt on the same
file descriptor at the same time?A: Yes, but assume two threads A (writes a one message containing
“aaaa”) and B which (writes two messages “b” and “c”).You can be assured that B’s data won’t show up inside A’s message: ie.
never aabaa but you can not assume anything about the order they will
be received.摘自:
https://linux-newbie.vger.kernel.narkive.com/wdAJ3Kzt/is-sendto-and-recvfrom-thread-safe#post4
对于 UDP,多线程读写同一个 socket 不用加锁,不过更好的做法是每个线程有自己的 socket,避免 contention,可以用 SO_REUSEPORT 来实现这一点。
摘自知乎陈硕大佬的回答: https://www.zhihu.com/question/56899596/answer/150926723
4.5 fwrite是不是线程安全的
fwrite是c/c++语言库实现的,它最终是调用write系统调用,但是做了一些封装,关于FILE*等可以看我之前的内容:https://www.cnblogs.com/whuwzp/p/stdin_stdout_fflush_file.html。
另外这篇文章用实验证明了fwrite的线程安全性:https://cloud.tencent.com/developer/article/1412015
(但是我和他关于write的线程安全性的实验的结果是不一样的,我测试是也具备线程安全性的,即使不加APPEND模式)
在同一个进程内, 针对同一个FILE的操作(比如fwrite), 是线程安全的. 当然这只在POSIX兼容的系统上成立, Windows上的FILE的操作并不是线程安全的.
http://gcc.gnu.org/onlinedocs/libstdc++/manual/using_concurrency.htmlAs
a n example, the POSIX standard requires that C stdio FILE* operations
are atomic. POSIX-conforming C libraries (e.g, on Solaris and
GNU/Linux) have an internal mutex to serialize operations on FILE*s.
However, you still need to not do stupid things like calling
fclose(fs) in one thread followed by an access of fs in another.摘自知乎大神egmkang wang的回答:
https://www.zhihu.com/question/40472431/answer/87077691It depends upon which primitives you’re using to submit data to the
socket.If you’re using write(2), send(2), sendto(2), or sendmsg(2) and the
size of your message is small enough to fit entirely within the kernel
buffers for the socket, then that entire write will be sent as a block
without other data being interspersed.摘自:https://stackoverflow.com/questions/7942595/linux-c-c-socket-send-in-multi-thread-code
4.6 Windows 平台,java语言,c++ boost库 相关讨论
Winsock环境下UDP不存在线程安全问题而tcp存在的资料:https://stackoverflow.com/questions/13983398/is-winsock2-thread-safe
Windows 下使用多个线程send,结论是无法保证顺序,以及内容会不会交错:https://tangentsoft.net/wskfaq/intermediate.html#threadsafety
一个boost的测试,结论是boost的不是线程安全的(多个线程同时send):
https://stackoverflow.com/questions/11581978/write-to-boostasio-socket-from-different-threads
java里面的,高概率数据交错,除非两个线程自有顺序(不同时),或者写入是原子操作,不然很可能交错:https://stackoverflow.com/questions/18910022/can-two-threads-use-the-same-socket-at-the-same-time-and-are-there-possible-pro
5. 建议和替代方案
Linux下:改成消息队列模型,多个线程把数据发送到队列中(队列用mutex等保证线程安全),然后仅由一个线程负责取出消息队列中的消息,发送给对端。可以参考libevent和这篇文章http://pl.atyp.us/content/tech/servers.html。
Windows下:asynchronous IO or overlapped IO (这个不太熟)
6. 参考网址
IBM文章:原文已失效,转载的:https://www.cnblogs.com/whuwzp/articles/thread_safe
UDP不存在线程安全问题的资料:https://linux-newbie.vger.kernel.narkive.com/wdAJ3Kzt/is-sendto-and-recvfrom-thread-safe#post4
Winsock环境下UDP不存在线程安全问题而tcp存在的资料:https://stackoverflow.com/questions/13983398/is-winsock2-thread-safe
Windows 下使用多个线程send,结论是无法保证顺序,以及内容会不会交错:https://tangentsoft.net/wskfaq/intermediate.html#threadsafety
一个boost的测试,结论是boost的不是线程安全的(多个线程同时send):
https://stackoverflow.com/questions/11581978/write-to-boostasio-socket-from-different-threads
java里面的,高概率数据交错,除非两个线程自有顺序(不同时),或者写入是原子操作,不然很可能交错:https://stackoverflow.com/questions/18910022/can-two-threads-use-the-same-socket-at-the-same-time-and-are-there-possible-pro
和我相同问题的:https://stackoverflow.com/questions/1457256/is-it-safe-to-issue-blocking-write-calls-on-the-same-tcp-socket-from-multiple
原子和线程安全的讨论: https://softwareengineering.stackexchange.com/questions/178898/difference-between-atomic-operation-and-thread-safety
原子和线程安全的讨论: https://stackoverflow.com/questions/12347236/which-is-threadsafe-atomic-or-non-atomic
知乎陈硕大佬的关于该问题加锁解决的方法的评价: https://www.zhihu.com/question/56899596/answer/150926723
知乎大神egmkang wang的回答fwrite线程安全性: https://www.zhihu.com/question/40472431/answer/87077691