本文首发于Amber's Blog-流式套接字学习笔记
流式套接字依托TCP协议提供面向连接的、可靠的数据传输服务,基于流的特点,使用流式套接字传输的数据形态是没有记录边界的有序数据流。这篇文章是对《Windows网络编程》第五章“流式套接字编程”有关内容的总结,也梳理了我在学习过程中遇到有关问题。
流式套接字的通信过程
先贴出一张这部分内容至关重要的图,展示了流式套接字的具体通信过程与相关的函数操作
流式套接字服务器的工作原理
TCP服务器在工作过程中将监听与传输区分开来,如上图所示,服务器为客户的连接请求分配了新的套接字(连接套接字)。实际套接字就是建立在新的连接套接字和客户的套接字之间的,作为服务器与该客户之间的专一通道。而原本处于监听的套接字一直处于监听状态,等待其他客户的连接请求。
流式套接字相关函数与操作
这张图再来一遍( ̄▽ ̄)"
[图片上传失败...(image-6490a2-1560949300203)]
根据上图,我们一起来看看通信过程中的基本函数与操作
创建和关闭套接字
在WinSock2中完成这个任务的函数式socket()
和WSASocket()
// socket()函数定义
SOCKET WSAAPI socket(
_in int af,
_in int type,
_in int protocol
);
- af:确定套接字的通信地址族。目前主要是 IPv4(AF_INET)。
- typr:指定套接字类型。如流式套接字:
SOCK_STREAM
,数据报套接字:SOCK_DGRAM
。 - protocol:指定传输协议,一般为0。
socket()
与WSASocket()
的返回值是通信实例的句柄,类似文件描述符
调用closesocket()
关闭套接字
int closesocket(
_in SOCKET s;
);
成功返回0,否则返回-1
指定地址
使用套接字需要知道通信的本地和远程地址才能进行数据传输,当套接字创建后,并未关联具体的地址。需要通过bind()
函数将一本地名字赋予一个未命名的套接字
int bind(
_in SOCKET s,
_in const struct sockaddr *name;
_in int namelen
);
- s:调用
socket()
返回的描述符。 - name:地址参数,一个指向
sockaddr
结构的指针。通常使用sockaddr_in
进行地址赋值,然后进行强制类型转换为sockaddr
。
// sockaddr与sockaddr_in
struct sockaddr {
ushort sa_family;
char sa_data[14];
};
struct sockaddr_in {
short sin_faily;
u_short sin_port;
struct in_addr sin_adr;
char sin_zero[8];
}
/*
sockaddr与sockaddr_in长度一致,其实后者可以看作前者结构中的数据更详细的视图
*/
- namelen:地址结构的大小。
如果成功,bind()
函数返回0;否则返回-1
几个关于bind()
函数的问题
- 具有多个Internet地址的主机上的服务器进程如何为客户端提供服务
可以将sockaddr
设定为INADDR_ANY
地址,此时套接字对主机上所有网卡的数据进行检查,只要其协议和目标端口与该套接字一致就接收该数据。
- 客户是否需要
bind()
操作
bind()
操作将本地地址与套接字关联起来,因此作为通信的另一方客户也需要进行bind()
操作,只不过客户不需要显式地进行bind()
操作,而是在调用connect()
或sendto()
发送数据前,由系统随机指定一个端口号,并隐式地调用bind()
操作
-
bind()
实现了套接字与本地地址关联,如何获取套接字的远端地址
当流式套接字建立好连接后,其远端的端点地址也与套接字关联起来,这些信息存储在套接字的内存结构中
连接套接字
请求连接
客户调用connect()
请求与服务器连接
int connect(
_in SOCKET s,
_in const struct sockaddr *name,
_in int namelen
);
connect()
函数执行的操作:
- 对于未绑定的套接字,隐式调用
bind()
操作。 - 在
name
参数中声明服务器地址。 - 触发协议栈向目标地址发送SYN请求,完成TCP三次握手。
- 等待服务器响应,由于网络存在的延时,服务器可能无法立刻返回。但
connect()
函数成功返回即意味着可以到达目标服务器。
处理进入的连接
服务器在绑定后需要进行监听来自客户端的请求,通过在套接字上调用listen()
完成:
int listen(
_in SOCKET s,
_in int backlog
);
listen()
函数使服务器进入监听状态,使其对进入的TCP连接请求进行排队。backlog
参数指明了改等待队列的上限。
此后服务器要从已完成连接的请求队列中取出某连接请求,调用accept()
函数完成:
SOCKET accept(
_in SOCKET s,
_out struct sockaddr *addr,
_inout int *addrlen
);
- s:监听套接字
accept()
函数返回另一个用于数据传输的套接字(连接套接字),连接套接字实际负责连接通信。
套接字 | 区别 |
---|---|
监听套接字 | 一个服务器通常只创建一个监听套接字,在服务器生命期中一直存在 |
连接套接字 | 为每个连接创建一个连接套接字,连接关闭,相应套接字关闭 |
数据传输
发送数据
send()
函数进行数据发送:
int send(
_in SOCKET s,
_in const char *buf,
_in int len,
_in int flags
);
- s:连接套接字
- buf:指向要发送的字节序列
- len:发送的字节数
- flags:默认为0
send()
函数的返回值说明了实际发送的字节总数,默认情况下,send()
函数的调用会一直阻塞直到发送了所有数据为止。
接收数据
recv()
函数进行数据接收:
int recv(
_in SOCKET s,
_out char *buf,
_in int len,
_in int flags
)
- s:连接套接字
- buf:指向要保存接收数据的的应用程序缓冲区
- len:接收缓冲区的字节长度
- flag:默认为0
recv()
函数的返回值说明了实际接收的字节总数。
注意,对于发送与接收数据的两种操作没有服务器与客户的区别。
一个关于监听套接字与连接套接字的问题
再回到这张图,我们已经明白,服务器与客户之间的通信实际是由accept()
函数分配的新的套接字——连接套接字完成的。如果我们监听套接字绑定了本地的80端口,那新建立的连接套接字的端口号是多少呢?
我们不妨先假设:连接套接字使用了一个新的端口,那么服务器与客户的通信实际是由这个新的端口进行传输的。但是通过Wireshark抓包分析发现,如果我们与服务器进行HTTP通信,双方在通信的过程中一直使用的是服务器的80端口。
数据包不会骗人,那么连接套接字只能是与监听套接字使用了同一个端口,可如果所有的连接套接字和监听端口都使用了同一个端口,如何进行区分呢?客户的数据是如何传送到与之相关联的连接套接字上去的呢?
观察SOCKET
结构体就可以发现,它不仅记录了本地通信的地址,也记录了远端的通信地址。当数据到达了上述场景中80端口的接收缓冲区时,每个连接套接字通过自己记录的远端地址从中选择了发送给自己数据。这样虽然使用的是同一个端口,但并不会发生数据的混乱。