套接字(Socket)编程(二) 内部通信原理

起初我觉得学习套接字并不需要知道内部的通信原理,因为这些都是由系统来处理,但是随着后来的深入我发现了这个错误的想法,后面有关套接字的相关设置都与内部通信原理息息相关(包括后来的设置套接字可选项、套接字I/O缓存),今天一起来探究一下套接字的内部的通信原理

上一篇我们聊了Socket连接创建及通信流程的一些基本函数 《套接字(Socket)编程(一) 函数概念篇》,这篇我们将来聊一聊TCP的连接和断开TCP套接字I/O缓冲区UDP套接字I/O缓冲区UDP套接字的无连接和有连接模式

关于下面说到的TCP套接字连接握手、断开握手以及中间的数据交互涉及到的 SYN、ACK、FIN、SEQ等关键词请参考我后面一片文章《TCP/IP 协议及数据格式》,文章 “3.2 TCP协议数据报的头”部分,里面有详细的讲解;关于连接、断开和通信过程中ACK和SEQ的数值变化以及交互的细节可以参考这篇文章里面的 “3.3 TCP通信数据交互细节和实践”部分,里面有详细的数据抓取和讲解。

一、TCP连接的三次握手和断开的四次握手

在讲到下面函数之前,我们不得不先说下连接过程和端口的过程,虽然我们用C代码写连接的时候看不到握手的过程,但是里面的参数我们是可以通过相关函数调用进行设置,后面讲的函数很多关乎到这里面的参数设置

首先来说下TCP套接字连接进程中的10种状态

状态 描述
LISTEN 侦听来自远端TCP协议端口的连接请求
SYN-SENT 发送连接请求后,等待匹配的连接请求
SYN-RECEIVED 收到和发送一个连接请求后,等待确认
ESTABLISHED 连接已经打开,可以发送或接收数据
FIN-WAIT-1 发送了中断连接请求,等待对方确认
FIN-WAIT-2 收到对方对于中断请求的确认,等待对方的中断请求
TIME-WAIT 等待足够的时间,以保证远端收到连接中断请求的确认
CLOSE-WAIT 等待本地应用层发来中断请求
LAST-ACK 等待远端TCP协议对连接中断的确认
CLOSED 没有任何连接
1.1 TCP连接时的三次握手
TCP建立的三次握手.png

步骤:
①第一条消息为SYN消息Synchronization同步消息,表示收发数据前传输的同步消息
SEQ = x:表示现在传递的数据包的序号为x,如果接收无误,请通知我向你传递x+1号数据包
②接着服务器会回复SYN+ACK类型数据消息,服务器对客户端首次传输的数据包确认(ACK)和服务器传输数据做准备的同步消息(SYN)捆绑发送
ACK = x+1:表示刚才客户端的序号为x的数据包接收无误,接下来请传输序号为x+1的数据包
SEQ = y:表示现传递的数据包序号为y,如果接收无误,请通知我向你传输y+1号数据包
③客户端回复服务器ACK消息
SEQ = x+1:表示向服务器传递序号为x+1数据包
ACK = y+1:表示接受服务器端的序号为y的数据包接收无误,接下来可以传输序号为y+1的数据包

连接三次握手为了防止已失效的连接请求报文段突然又传到了服务器,导致服务器误认为客户端想请求连接而发生连接的错误
举个例子:在两次握手的前提下,Client发出连接请求,但因为丢失了,故而不能收到Server的确认。于是Client重新发出请求,然后收到确认,建立连接,数据传输完毕后,释放连接,Client发了2个,但是,某种情况下,Client发出的第一个连接请求在某个节点滞留了,延误到达Server。假设此时Server已经释放连接,那么Server在收到此实现的连接请求后,就误认为Client又发出一次连接请求,在两次握手的情况下(Client发生请求,Server接受请求并确认),Server就认为Client又发出一次新连接请求。此时Server就又给Client发生一个确认,表示同意建立连接。因为是两次握手,Client收到后,也不再次发出确认连接。此时Server会等待Client发送的数据,而Client本来就没有要求发送数据,肯定也无动于衷。此时Server的资源就被浪费了。

1.2 TCP数据交换

通过第一步的三次握手过程完成数据交换,下面就正式开始收发数据,其默认方式如下图

TCP数据交换工作原理.png

如图:客户端分两次向服务器传递了200字节的过程,首先客户端通过一个数据包发送100个字节,数据包的SEQ为1200,服务器为了确认这一点,向客户端发送ACK为1300消息。
此时ACK号为1300而非1200,也不是1301,原因在于ACK号的增量为传递的数据字节数,假设每次ACK号不加传输的字节,这样虽然可以确认数据包的传输,但无法确认100字节全部正确传递还是丢失了一部分,比如只传递了80个字节,按公式计算传递ACK消息ACK号 = SEQ号 + 传递的字节数
网上和书上很多说法说这里ACK值的计算应该是ACK号 = SEQ号 + 传递的字节数 + 1,实际这种说法是不准确的,在连接的三次握手和断开的4次握手里面,每次传输数据长度为0(这些数据报只有报头),回复确认都是ACK号 = 接收到数据报的SEQ号 + 1,但是除了这特别的情况外(连接完成,正常通信过程中),每个数据报的SEQ都是这段TCP数据首个字节的序号,按上面的图来说,第一次传输时SEQ = 1200,其实序列号1200也是该段数据第一个字节的序号,那么第二个字节的序号就是1201,传输了100个字节,该数据段的最后一个字节的序号应该是1299,下次需要传输的数据要从1300开始,所以服务器回复的确认号是1300,而不是1301,这里需要注意下。
客户端在规定时间没有收到服务器的确认消息,则认为丢失,然后重传

2. 断开时的四次握手

对于TCP的断开也是非常优雅的,如下图

TCP断开四次握手.png

大概流程简单翻译:
客户端:“我希望断开连接”
服务器:“哦,是吗?请稍等”

服务器:“我也准备就绪,可以断开连接”
客户端:“好的,谢谢合作”

如上图所示,数据包内的FIN表示断开连接,也就是说双方各发送1次FIN消息后断开连接,此过程经理4个阶段,因此又称四次握手,SEQ和ACK前面已经做过解释,故省略。

注意:
服务器收到客户端连FIN报文段后就立即发送确认,然后就进入close-wait状态,此时TCP服务器进程就通知高层应用进程,此时是“半关闭”状态。即客户端不可以发送数据到服务器,但是服务器可以发送数据给客户端。
此时,若服务器没有数据报要发送给客户端了,其应用进程就通知TCP释放连接,然后发送给客户端FIN报文段,并等待确认。
客户端发送确认后,进入time-wait,注意,此时TCP连接还没有释放掉,然后经过时间等待计时器设置的2MSL后,客户端才进入到close状态。
为什么要等待呢?
①、为了保证客户端发送的最后一个ACK报文段能够到达服务器。即最后这个确认报文段很有可能丢失,那么服务器会超时重传,然后客户端再一次确认,同时启动2MSL计时器,如此下去。如果没有等待时间,发送完确认报文段就立即释放连接的话,服务器就无法重传了(连接已被释放,任何数据都不能出传了),因而也就收不到确认,就无法按照步骤进入CLOSE状态,即必须收到确认才能close,流程看下图

TCP断开Wait-Time.png

②、防止“已失效的连接请求报文段”出现在连接中。经过2MSL,那些在这个连接持续的时间内,产生的所有报文段就可以都从网络中消失。即在这个连接释放的过程中会有一些无效的报文段滞留在楼阁结点,但是呢,经过2MSL这些无效报文段就肯定可以发送到目的地,不会滞留在网络中。这样的话,在下一个连接中就不会出现上一个连接遗留下来的请求报文段了。
可以看出:服务器结束TCP连接的时间比客户端早一点,因为服务器收到确认就断开连接了,而客户端还得等待Time-Wait,虽然这个Time-Wait看似重要,但是在实际开发中并不那么讨人喜欢,后面的一片文章里面有介绍怎么去掉这个Time-Wait的等待(套接字(Socket)编程(三) 套接字可选项)。

上面经过四次握手断开的属于正常断开,经过四次握手双方都知道连接断开了,但是平时通信的过程中有各种原因会造成异常断开,比方说服务器断电或者客户端断电...一般的TCP通信中有两种方式来解决这个异常:①、自己在应用层定时发送心跳包来判断连接是否正常,此方法比较通用,灵活可控,但改变了现有的协议;②、使用TCP的keepalive机制,TCP协议自带的保活功能,使用起来简单,减少了应用层代码的复杂度, 推测也会更节省流量,因为一般来说应用层的数据传输到协议层时都会被加上额外的包头包尾,由TCP协议提供的检活,其发的探测包,理论上实现的会更精妙(用更少的字节完成更多的目标),耗费更少的流量;具体请看后面文章实现,这里先不做深聊

二、TCP套接字中的I/O缓冲区

如当前所述,TCP套接字收发无边界,服务器端调用1次send函数传输40个字节,客户端也能通过4次调用recv函数每次读10个字节,那么这个时候问题就来了,服务器一次发送了40个字节的数据,而客户端则可以缓慢分批读取,客户端读取了10个字节后剩余的30个字节的数据在什么地方呢?
实际上调用send函数不是立即发送数据,调用recv函数也并非立马接收数据,更精确的讲,send函数调用瞬间,将数据移至输出缓冲区,recv函数调用瞬间,从输入缓冲区读取数据,入下图所示

TCP套接字的I/O缓冲区.png

如图所示,调用send函数将数据移至缓冲区,在适当的时候(不管是分批传送还是一次性传送)传向对方的输入缓冲区,这个时对方将调用recv函数从自己的输入缓冲队列里面读取对方发送过来的数据。

特性

  • I/O缓冲区在每个套接字中单独存在
  • I/O缓冲区在创建套接字时自动生成
  • 即使关闭套接字,也可以继续传送输入缓冲区里面的数据
  • 关闭套接字将丢失输入缓冲区里面的数据

三、UDP套接字中的I/O缓冲区

前面说过TCP数据传输中不存在边界,这表示数据传输过程中调用I/O函数的次数不具有任何意义,相反UDP是具有数据边界的协议,传输中调用I/O函数的次数非常重要,因为输入函数的调用次数应和输出函数的调用次数完全一致,这样才能保证接收全部已发送数据,例如调用了3次输出函数发送数据,就必须调用3次输入函数才能完成接收,下面通过简单的例子来印证下

发送UDP数据端代码

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>

int sendPacket(void);

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        int send_result = sendPacket();
        if (send_result == 0) printf("开启发送失败\n");
    }
    return 0;
}

#pragma mark ---广播
int sendPacket()
{
    printf("请输入UDP数据传送IP地址:");
    char ip[INET_ADDRSTRLEN];
    scanf("%s",ip);
    
    int send_sock;
    send_sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (send_sock < 0) return 0;
    
    struct sockaddr_in addr;
    addr.sin_len = sizeof(struct sockaddr_in);
    addr.sin_family = AF_INET;
    addr.sin_port = htons(2001);
    inet_aton(ip, &addr.sin_addr);
    
    printf("开始连续三次发送数据\n");
    char msg1[] = "Hi";
    char msg2[] = "Hello";
    char msg3[] = "Nice to meet you";
    sendto(send_sock, msg1, sizeof(msg1), 0, (struct sockaddr*)&addr, addr.sin_len);
    sendto(send_sock, msg2, sizeof(msg2), 0, (struct sockaddr*)&addr, addr.sin_len);
    sendto(send_sock, msg3, sizeof(msg3), 0, (struct sockaddr*)&addr, addr.sin_len);
    
    printf("关闭UDP套接字\n");
    close(send_sock);
    
    return 1;
}

接收UDP数据端代码

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>

int recvPacket(void);

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        int recv_result = recvPacket();
        if (recv_result == 0) printf("开启接收失败\n");
    }
    return 0;
}

#pragma mark ---UDP数据接收端
int recvPacket()
{
    int recv_sock;
    recv_sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (recv_sock < 0) return 0;
    
    struct sockaddr_in addr,recv_addr;
    socklen_t len = sizeof(struct sockaddr_in);
    
    addr.sin_family = AF_INET;
    addr.sin_len = sizeof(struct sockaddr_in);
    addr.sin_port = htons(2001);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    
    if (bind(recv_sock, (struct sockaddr*)&addr, addr.sin_len) < 0) {
        printf("%s\n",strerror(errno));
        return 0;
    }
    
    printf("开始接收UDP数据\n");
    char recv_buffer[512];
    for (int i = 0; i<3; i++) {
        
        sleep(5);
        
        memset(recv_buffer, 0, 512);
        ssize_t recv_len = recvfrom(recv_sock,
                                    recv_buffer,
                                    sizeof(recv_buffer),
                                    0,
                                    (struct sockaddr*)&recv_addr,
                                    &len);
        if (recv_len <= 0) {
            printf("接收数据失败:%s\n",strerror(errno));
            break;
        }
        printf("recv: %s\n",recv_buffer);
    }
    printf("关闭UDP套接字\n");
    close(recv_sock);
    return 1;
}

UDP发送端打印

/**
 *  请输入UDP数据传送IP地址:10.22.70.99
 *  开始连续三次发送数据
 *  关闭UDP套接字
 */

UDP接收端打印

/**
 *  开始接收UDP数据
 *  recv: Hi
 *  recv: Hello
 *  recv: Nice to meet you
 *  关闭UDP套接字
 */

结论
从上面的例子可以看出,发送端连续发送了三个UDP数据包,而接收端轮循了三次去接收,每次接收到都等待5s再进行下次数据读取,也就是说接收端在读第一次数据的时候,其实输入缓冲区里面已经有三组数据,但是他只读取了最前面的一组,这正好说明了发送端和接收端在传输数据的过程中调用I/O数据次数要一致才能将全部数据读取完

解释
UDP套接字传输的数据包又称数据报,实际上数据报也属于数据包的一种,只是与TCP包不同,其本身可以成为一个完整数据,这与UDP数据传输特性有关,UDP中存在数据边界,1个数据包即可以成为1个完整数据,因此称为数据报。

四、UDP套接字的连接(connected)和非连接(unconnected)

TCP套接字传输数据需要注册目的地址的IP和端口号信息,而UDP中则无需注册,于是通过sendto()函数发发送数据的流程大概如下
① 向UDP套接字中注册目标IP和端口号
② 传输数据
③ 删除套接字中注册的IP和端口号
每次调用sendto()函数,每次都重复上面步骤变更目标地址,因此可以利用同一个UDP套接字向不同的地址发送数据,这种未注册目标地址信息的套接字称为未连接套接字,相反注册过地址的套接字称为连接套接字,显然UDP默认套接字为未连接套接字。
在平时开发中有这么一种情况,需要向同一个地址连续发送UDP数据,比方说前面的《TFTP服务器和TFTP客户端》,需要向同一个客户端地址连续发送数据包和接收该地址发送过来的确认包,这个时候如果还用无连接模式发送数据,上述的第一个阶段和第三个阶段占整个通信过程近1/3的时间。

创建已连接UDP套接字
创建已连接UDP套接字,跟前面创建未连接UDP套接字步骤一样,只不过多调用了connect函数,创建步骤如下:
int sock = socket(AF_INET, SOCK_DGRAM, 0);
addr.sin_family = AF_INET;
addr.sin_port = ....
addr.sin_addr.s_addr = ...
connect(sock, (struct sockaddr*)&addr, sizeof(addr));
针对UDP套接字调用connect()函数并不意味着要与对方套接字连接,只是向UDP套接字注册目标IP和端口信息,之后就与TCP套接字一样,每次调用sendto()函数时只需传输数据,因为已经指定了接收对象,所以不仅可以使用sendto()recvfrom函数,还可以使用send()recv()函数进行数据传送和读取。

五、尾声

接下来的文章会继续更新有关套接字的详解,这是我起初学习的一个流程,希望也对大家有帮助!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268