asynSocket源码解析之三
有一段时间一直被一个socket问题纠结。
现实情景
现实情景如下(服务/客户端都采用Tcp协议,都采用异步io):
- 我有一个服务端:假设服务端socket系统写缓存区为50K字节,不存在性能问题。
- 我有一个客户端:假设socket系统读缓存区设置为 5K字节,客户端由于一些问题(假设为性能问题),不能及时读取服务端发送过来的数据。
在这种情况下,数据户不会丢呢?客户端socket系统读缓存区设置的大小,对服务端有影响吗?
为了描述方面,后面我们经常要用到两个名词,一个是服务端socket写buffer指代服务端socket系统写缓存区,客户端socket读buffer指代客户端socket系统读缓存区。
假设场景
一般服务端socket存在读/写行为,客户端socket也存在读和写行为。一会读一会写这样分析问题就很复杂。为了更好分析上面的问题,我们假设有这样一个假设场景
假设服务端客户端都采用TCP协议通过异步socket通信;客户端和服务端socket连接一切正常;服务端socket写buffer有50K(假设系统设置是多大空间实际使用就是大多空间),客户端socket读buffer有5K(假设系统设置是多大空间实际使用就是大多空间);客户端不调用read读取数据,也不调用write写数据;服务端不调用read读取数据,但是要连续调用write一次发送5K,共计发送1M数据到客户端。
下面所有讨论和分析都基于上面假设场景。
问题一:
注意上面假设客户端socket读buffer只有5K,客户端不调用read读数据。
- 问题1.1:第一次服务端调用write刚好发送5K数据能发送成功吗?(注意上面假设条件:客户端不调用read读取数据)
如果你认为问题1.1能够发送成功,那么请继续思考。
- 问题1.2:服务端第二次调用write再发送5K字节数据能成功吗?注意上面假设条件:客户端socket系统读缓存区为5K)
我先告诉你结果:问题1.1能够发送成功,问题1.2也能够发送成功。如果你知道为什么?首先我们要知道知道socket调用write到时先向本地socket写buffer填充数据,然后底层负责把数据传输到对方的socket读buffer。当服务端调用write发送5K数据,发现现在有50K缓存可用,那调用wrire会立即返回成功。不是等到数据成功到达客户端socket读buffer才返回,也不是等到客户端调用read把数据读取到内存才返回。
下面我画了一个图,简单描述服务端socket写buffer和客户端socket读buffer的关系。
Paste_Image.png
如果上面你暂时不能理解,你也可以继续阅读。因为后面我们会写一个demo,包含服务端,客户端,模拟上面说的场景。你可以运行demo验证一下。
问题二:
一定要记住,上面假设过客户端始终不调用read读取数据。服务器端继续发送剩下的数据,按每次write发送5K数据计算。那么第10次发送的时候,服务端socket写buffer刚好被填满(因为上面假设服务端socket写buffer只有50K)。那现在考虑第11次调用write再发送5K数据。这时候我们来分析下面问题。
- 问题2.1:服务端第11次调用write能成功吗?
- 问题2.2:假设第X(x>11)次调用write写入失败。write返回值是多少呢?
- 问题2.3:服务端最多能向socket成功写入多少数据?
基于上面假设场景提出的问题,下面会做一些解答。然后再拿demo验证。
- 解答问题一:服务端调用write发送数据的时候,并不是等到write发送的数据成功到达客户端之后才返回。而是先写入服务端socket系统缓存中,写完之后就返回。第二次调用write发送数据的时候,服务端socket系统缓存至少有45K,第二次写入5K数据当然会成功。
- 解答问题二:服务端有socket系统缓存有50K,客户端socket系统缓存有5K。那么第10次服务端调用write发送数据的时候,服务端共计已经写入50K数据到socket系统缓存。在服务端第11次调用write发送5K的时候,正常情况下是能正常返回的,但不是绝对。55K怎么会成功写入到只有50K的socket系统缓存?我们可以这样想,write每次想socket写入数据的时候,TCP协议已经在帮我们把socket系统缓存的数据向客户端发送,即使客户端不调用read函数(其实read只读socket系统读buffer),可能在我们第x次调用write的时候,服务端第一次写入的5k数据,已经被发送到了客户端(客户端正好有5k的socket系统读buffer)。所以第11次能不能写成功,和当前网络,及调用write的时间间隔有关。
- 解答问题四:所以如果服务端的socket系统读buffer只有50K,客户端socket系统读buffer只有5K,那么,服务端调用write最多能写入55K数据(50K服务端,5k在客户端)。
验证
demo简介:
demo 基于CocoaAsyncSocket改进。里面有一个EchoServer模拟服务端不停发送数据。IPhoneConnectTest模拟客户端,只连上服务器端用。不调用read读取数据。用到一个第三方log库和GCDAsyncSocket最新代码。为了模拟一些条件,GCDAsyncSocket做了一些改动。具体改动后面会简要说一下。具体可以看git 提交节点及说明。
其中有一个Config.h文件:
#ifndef Config_h
#define Config_h
//IPhoneConnectTest demo 使用。注意iphone 和 mac端需要运行在一个局域网下
#define kConnectIp @"10.0.100.109"
//IPhoneConnectTest demo 和 EchoServer demo 同时使用一个端口
#define kConnectPort 5555
#endif /* Config_h */
测试的时候要记住把客户端(IPhoneConnectTest)kConnectIp 改成mac电脑的ip地址。如下截图的192.168.1.102
屏幕快照 2017-01-18 23.27.09.pngdemo服务端分析:
EchoServer改自CocoaAsynSocket的一个例子。增加一个点击触发事件:
- (IBAction)send:(id)sender{
GCDAsyncSocket *lastSocket = [connectedSockets lastObject];
int sendByte = 1024;
char test[sendByte+1];
char *p = test;
for (NSInteger i = 0; i < sendByte; i++) {
*p = 'a';
p++;
}
*p = '\0';
NSInteger count = 0;
while (count++ < 1*1024) {
NSString *welcomeMsg = [NSString stringWithCString:test encoding:NSUTF8StringEncoding];
NSData *welcomeData = [welcomeMsg dataUsingEncoding:NSUTF8StringEncoding];
[lastSocket writeData:welcomeData withTimeout:-1 tag:WELCOME_MSG];
}
}
点击一次大概促发110241024 = 1M数据。
GCDAsynSocket内部源码改动
1. GCDAsynSocket内部增加一个设置socket系统读写buffer大小的方法。
//设置socket系统的读写buffer。
- (void)setSocket:(int)socket bufferSize:(int)size
主要用来当EchoServer和IphoneConnectTest客户端建立连接之后,我们设置服务端socket系统的读写buffer 都为50K。
设置代码如下:
[self setSocket:childSocketFD bufferSize:1024*50];
2.增加一个统计服务端成功写入数据的属性
//统计实际调用write方法的次数。
- @property (atomic, assign, readwrite) NSInteger writeCount;
//统计实际写入数据的字节数
- @property (atomic, assign, readwrite) NSInteger writeByetes;
在doWriteData做了如下改动,统计writeByetes数据。并在if (errno == EWOULDBLOCK)的时候注释掉,socket唤醒通知(后面IphoneConnectTest会尝试读取所有服务器写入数据)用来验证服务器端写入的数据,只要socket保持连接,客户端任何时候,只要想读就能读取所有服务端写入的数据。
- (void)doWriteData
{
...
ssize_t result = write(socketFD, buffer, (size_t)bytesToWrite);
if (result > 0) {
self.writeByetes += result;
}
LogVerbose(@"wrote to socket = %zd count=%zd bytes=%zd", result,self.writeCount,self.writeByetes);
// Check results
if (result < 0)
{
if (errno == EWOULDBLOCK)
{
waiting = YES;
}
else
{
error = [self errnoErrorWithReason:@"Error in write() function"];
}
}
....
if (waiting)
{
// flags &= ~kSocketCanAcceptBytes;
//
// if (![self usingCFStreamForTLS])
// {
// [self resumeWriteSource];
// }
}
}
demo客户端分析:
IphoneConnectTest改自CocoaAsynSocket的一个例子。增加两个读取数据的函数。后面我们会用来统计服务端写入的数据,客户端想读的数据是否能够读取。
- (void)readLength{
[asyncSocket readDataToLength:1024 withTimeout:-1 tag:100];
}
- (void)readData{
[asyncSocket readDataWithTimeout:-1 tag:101];
}
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag{
if (tag == 100) {
DDLogVerbose(@"readLength = %zd",data.length);
}else{
DDLogVerbose(@"readDataToData = %zd",data.length);
}
}
CGDAsynSocket内部改动:
当和EchoServer建立连接的时候设置IphoneConnectTest的socket系统读写buffer为5K
[self setSocket:socketFD bufferSize:5*1024];
当IphoneConnectTest通过GCD监听到socket数据发生变化的时候,注销掉doReadData函数。这样IphoneConnectTest只能收到socket有数据可读,但是不读取数据。
if (strongSelf->socketFDBytesAvailable > 0)
// [strongSelf doReadData];
else
[strongSelf doReadEOF];
测试case
第一步:下载CocoaAsyncSocketSample 或者 git clone https://github.com/upworldcjw/CocoaAsyncSocketSample.git
第二步:打开CocoaAsyncSocketSample文件夹,修改Config.h里面的
//对应Ip地址替换为本机ip地址
#define kConnectIp @"10.0.100.109"
端口号服务端和客户端默认都为5555(kConnectPort),可以修改也可以不修改(注意最好不要设置为常用端口号如8080,443等)。
第三步:运行EchoServer.xcodeproj,会启动mac版的服务端界面如下
Paste_Image.pngxcode终端输出log如下:
Paste_Image.png可以看出我们给EchoServer设置50K的发送/接收缓存区已经起效。
第四步:运行IPhoneConnectTest.xcodeproj,会启动iphone版客户端(此时选择真机调试)。当运行成功之后,EchoServer会收到连接消息界面显示如下:
Paste_Image.png
;IPhoneConnectTest客户端会终端会输出
Paste_Image.png
第五步:点击EchoServer的send按钮。会向客户端发送1024次数据,每次发送1024Byte数据。
Paste_Image.png
。
然后在服务端的终端上搜索socket = -1,会发现:
Paste_Image.png回过头来看看客户端log输出
Paste_Image.png看到socket = -1 count = 50 bytes=51200,来解释下这几个输出表示的意思。
socket = - 1,表示服务端返回的值为-1,也就是调用write返回失败。这种情况下errno都是为EWOULDBLOCK。这个解释可以参考上一篇博客,为了验证这个问题可以在下面对应的地方放歌断点,验证下。
Paste_Image.png
count = 50.表示我们累计调用write成功为50次,剩下的失败(服务端socket系统写buffer被填满)。
bytes=51200 为累计调用write成功写入数据的字节大小。目前可以计算刚好为50K。回头发现,我们把服务端socket系统写缓存设置的不就是50K。不用说肯定有关系。但是不要认为,客户端不读取buffer的时候,服务端最多能写入50K。其实正常情况下至少要>= 服务端socket设置的写缓存。下面分析下为啥这种情况下是50K,是因为服务端调用write太快,在2017-01-21 16:17:29:207 服务端socket写buffer 已经填满 (由于把后面的代码注释掉了,只要有一次写失败,以后就不会调用write)。但是客户端在2017-01-21 16:17:29:250 才第一次收到有数据回调。
真相如下:
Paste_Image.png Paste_Image.png
但是我们把客户端的socket读buffer设置成5K,为啥第一次就会回调有11264字节可读呢。先解释下这个可读,表示数据已经从服务端传输到客户端,在客户端socket的buffer里面存储)。11264 = 11K,明显远远大于客户端设置的5K,从另一个方面也说明,我们设置的buffer太小的时候,虽然api会返回成功,但是socket其实可用buffer要比实际设置的大。(这个实际大小和设置大小具体啥关系,还不是太清楚)。
修改条件继续测试。
关闭EchoServer 和 IphoneConnectTest。我们把第五步每次发送数据大小改为512Byte,发送1M数据。
继续上面的第三步,第四步,第五步。下面分别截图分析:
继续在EchoServer搜索socket = -1
客户端数据
Paste_Image.png发现这次服务端发送数据88064/1024 = 86K,客户端最多接收37328/1024 = 36.45K。发现一个规律 86K - 36.45k 大概等于 50K。哈哈,50K就是服务端socket系统buffer 大小。我给大家解释下:
下面简单画一个示意图
Paste_Image.png服务端socket系统写buffer和客户端socket系统读buffer,可以理解为两个存储池子。连接两个池子的是TCP协议,可以理解为管道。虽然服务端socket系统写buffer之后50K,但是你在写入的过程,tcp协议会一块一块的把服务端的数据搬到客户端socket系统读buffer。当成功搬过去一块数据,那么服务端的socket的系统写buffer,剩余可写空间就变大了,就可以继续写。如果客户端始终不读取socket数据。那么一般来说服务端最多可以写入 服务端socket实际可写buffer + 客户端socket实际可读buffer的大小。基本原理就是这了。关于验证,客户端不及时读取数据,等了好一会再读,数据不会丢失。这个我在IphoneConntectTest里面写了两个按钮,可以自己测试。