Socket编程(二)——协议的细节和选项

接上一篇,主要介绍一下Socket编程中如何体现TCP/IP族协议的一些细节,包括流量控制、半关闭等。

TCP连接过程

由于TCP协议是面向连接的,所以在进行数据传输前必须建立连接。

服务端方面

服务端首先需要创建自己的套接字,这个套接字并不是真正用于收发数据的,而是起到类似“门卫”的作用。服务端套接字绑定一个端口号后,便可以调用listen函数等待来自客户端的连接。当收到来自客户端的连接请求时,服务端就应该调用accept函数接受连接请求。回顾上一篇blog,该函数会返回一个套接字,这个套接字才是真实用于与客户端进行数据传输的。完成连接后就可以进数据传输了。

客户端方面

客户端方面的连接较为简单,只需要调用connect函数对服务端的指定端口发起连接请求即可。需要注意的是,客户端不需要指定端口号,而是由操作系统分配一个临时端口号。

“无数据边界”

之前提到过,TCP这类面向连接的协议是不存在数据边界的。考虑书本上的例子——回声服务端:即服务端会将客户端发送的内容原样发送回去。这样的功能看似很简单,但是如果使用面向连接的协议来实现,就需要考虑许多问题。例如:

  • 客户端发送的内容会先放在缓冲区,有可能会被分成多次发送。
  • 服务端接收数据的数据有可能也被分成多段,无法确定字符串的结尾。(服务端只调用一次read的话会导致读取内容不全)
  • 服务端发送的内容有可能在缓冲区被分成多次发送。

简而言之就是双方都不知道到底如何定义一个字符串的结束,这就要我们在应用层设计好协议来方便双方的数据传输。在传输层、网络层的协议都定义了固定格式的协议头,这就是为了保证数据传输过程中双方都能够理解,不会产生歧义。

在这个例子中,可以约定用数据的前4个字节作为字符串长度,接下来的若干个字节就是字符串的内容。在读数据时,使用循环来保证读取的内容达到规定的长度。当然,也可以规定某个字符作为结束符,实现方法不同,协议的特点也不同。

"无数据边界"意味着“数据传输过程中调用IO函数的次数不具有任何意义”。

UDP消息发送

惯例上,人们习惯以”数据报(包)“作为面向连接的数据单位,以”消息“作为不面向连接的数据单位。与TCP协议不同,由于不需要建立连接,客户端和服务端双方都只需要一个套接字,就像是“信箱”一样,而且一个套接字可以同时与多个主机通信。

IO函数

基于UDP的IO函数与TCP有所不同,毕竟实现原理都不同。

1
2
3
4
5
6
7
#include <sys/socket.h>
/*
* 前4个参数与TCP的函数类似,后两个参数用于指定发送的地址。
*/
ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen);

ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t addrlen);

上面函数与TCP的IO函数最大的区别就是直接在函数中指定了接收和发送数据的地址,也不需要使用bind函数来绑定端口号,而是调用sendtorecvfrom自动分配一个端口,这点比TCP更直接,也能够直接体现出UDP这类面向消息的协议的特点。

已连接(connected)UDP套接字

UDP的IO函数传输过程分为3个步骤:注册IP和端口、传输数据、删除IP和端口。如果通信的对象较为固定,频繁调用IO函数可能会花费较多时间在注册和删除IP端口上,所以可以创建已连接UDP套接字来提高性能。

1
2
sock = socket(PF_INET, SOCK_DGRAM, 0);
connect(sock, (struct sockaddr*)&adr, sizeof(adr))

上述代码创建了一个UDP类型的套接字,但调用了 connect函数,但这并不意味着使用这个套接字来建立连接,而是向目标套接字注册IP和端口信息。

优雅地断开套接字连接

TCP协议在通信结束后还会经历四次挥手来断开TCP连接,四次挥手中的前两次通信完成后的状态称为半关闭,即一方表明自己没有数据需要发送,但仍可以接受数据,待对方数据发送完后再完全断开连接。Socket编程中用于完成半关闭的函数为:

1
2
3
4
5
/*
* howto是传递断开的方式,有:SHUT_RD:断开输入流、SHUT_WR:断开输出流,SHUT_RDWR:同时断开输入输出流。
* 成功返回0,失败返回-1
*/
int shutdown(int sock, int howto);

上述三个常量在Windows平台下为SD_RECEIVE,SD_SEND,SD_BOTH

DNS域名服务

DNS的概念不难,主要介绍一下库中关于DNS的函数

1
2
3
4
5
6
7
8
9
10
11
12
#include<netdb.h>
struct hostent * gethostbyname(const cahr * hostname);

//
struct hostent
{
char * h_name; // official name
char ** h_aliases; // alias list
int h_addrtype; // host address type
int h_length; // address length
char ** h_addr_list // address list
}

比较值得注意的是h_addr_list参数,是一个链表,保存整数形式的域名链表。

相反的函数为:

1
struct hostent * gethostbyaddr(const char * addr, socklen_t len, int family);

套接字的可选项

进行套接字编程时可以根据需求对套接字的传输特性进行设置。下面两个是对可选项进行操作的函数:

1
2
3
4
5
6
/*
* level:可选项的协议层 optname:要查看的可选项名 optval:保存查看结果的缓冲区地址
*/
int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);

int setsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);

SO_REUSEADDR(重用地址)

在最初进行编程时,很经常发现服务端主动关闭后需要过一段时间(几分钟)才能再次在同样的端口上再次运行服务端。其原因就在于TCP断开连接的Time-wait状态。服务端主动关闭后,在四次挥手后还需要等待一个来回时间的Time-wait阶段来保证对方收到了自己上一个ACK报文,在这个过程中服务端原先绑定的端口不会被释放。所以短时间内再次运行客户端会出现bind error。

通过将SO_REUSEADDR这个可选项设置为true可以解决这个问题。

TCP_NODELAY

将这个可选项设置为true可以禁用Nagle算法,从而在传输较大数据时提高传输效率(但是可能会增加网络流量,影响传输)。

Nagle算法即在收到上段数据的ACK报文后再发送下一个段,在传输时间较长时Nagle算法会导致传输效率低下。

套接字的部分可选项


Socket编程(二)——协议的细节和选项
http://zhouhf.top/2023/03/12/Socket编程(二)——协议的细节和选项/
作者
周洪锋
发布于
2023年3月12日
许可协议