Loading

TCP Socket 编程原理详解

网络编程

socket

  Socket(套接字) 是网络编程的一种接口,它是一种特殊的 I/O。Socket可以理解为TCP/IP网络的API,它定义了许多函数或例程,程序员可以用它们来开发TCP/IP网络上的应用程序。电脑上运行的应用程序通常通过”套接字”向网络发出请求或者应答网络请求。在 TCP/IP 协议中,"IP地址+TCP或UDP端口号”可以唯一标识网络通讯中的一个进程。可以简单地认为 :"IP地址+端口号”就称为socket。在TCP协议中,建立连接的两个进程各自有一个socket来标识,这两个 socket组成的socket对就唯一标识一个连接。用socket函数建立一个socket连接,此函数返回一个整型的socket描述符,随后进行数据传输。

注意:一个完整的socket有一个本地唯一的socket文件描述符,由操作系统分配,可以在read和write函数中传入这个整型的socket文件描述符来在socket中进行读写。最重要的是,socket是面向客户/服务器模型而设计的。

  Socket是应用层与TCP/IP协议族通信的中间软件抽象层。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket后面,对用户来说只需要调用Socket规定的相关函数,让Socket去组织符合指定的协议数据然后进行通信。

  区分不同应用程序进程间的网络通信和连接,主要使用三个参数:通信的目的IP地址、使用的传输层协议(TCP或UDP)和使用的端口号。在编程时,就是使用这三个参数来构成一个 socket。这个 socket 相当于一个接口,可以进行不同计算机程序的信息传输。
  在TCP/IP世界中,socket接口是访问Internet最广泛的方法。在网络中如果有一台地址是192. 168 .0.5的FTP服务器,在另一台主机上运行一个FTP服务软件,执行命令 ftp 192.168.0.5, 在这台主机上将打开一个socket,并将其绑定21端口,与其建立连接并对话。
  因此,一个IP地址,一个通信端口,就能确定一个通信程序的位置。为此,开发人员专门设计了一个socket结构,就是把网络程序中所用到的网络地址和端口信息放在一个结构体中。
  一般,socket地址的结构都以"sockaddr"开头。socket根据所使用的协议的不同,可分为TCP socket和UDP socket,也称为流式socket和数据报socket。

socket 的数据结构

  在设计网络程序之前,应该了解两个重要的数据类型:sockaddr和sockaddr_in, 如图9. 2 所示。 这两个结构类型都是用来保存socket信息的,如IP地址、通信端口等,它们的具体说明如下所示:

sockaddr用来保存一个套接字,定义方法如下所示:

struct sockaddr
{
    unsigned short int sa_family;
    char sa_data[14];
};

在这个结构体中 ,成员的含义如下所示 :

  • sa_family:指定通信的地址类型。如果是TCP/IP通信,则该值为AF_INET。AF = Address Family,指用 IPv4 进行通信,AF_INET6 则是指用 IPv6 通信。
  • sa_data:最多使用14个字符长度,用来保存IP地址和端口信息。

sockaddr_in的功能与sockaddr相同,也是用来保存一个套接字的信息。不同的是将IP地址与端口分开为不同的成员。这个结构体的定义方法如下所示。

struct sockaddr_in
{
    unsigned short int sin_family;
    uintl6_t sin_port;
    struct in_addr sin_addr;
    unsigned char sin_zero[B];
};

这个结构体的成员与含义如下所示:

  • sin_family:与sockaddr结构体中的sa_family相同。
  • sin_port:套接字使用的端口号。
  • sin_addr:需要访问的IP地址。
  • sin_zero:未使用的字段,填充为0,

在这一结构体中,in_addr 也是一个结构体,定义方法如下所示,作用是保存一个IP地址。 其中,sockaddr_in 这个结构更方便使用,它可以轻松处理套接字地址的基本元素。

struct in_addr {
    in_addr_t s_addr;
};

注意:其中 sin_zero 用来将 sockaddr_in 结构填充到与 struct sockaddr 同样的长度 ,可以用 bzero() 函数将其置为零。这样, 即使 socket() 想要使用 struct sockaddr 结构 , 仍然可以使用 struct sockaddr_in 进行定义,只要用 bzero() 将sockaddr_in.sin_zero 置为零就可以转换了。

基于 TCP 协议的客户端/服务器程序的常用函数

  网络上绝大多数的通信服务采用服务器机制(Client/Server), TCP提供的是一种可靠的、面向连接的服务。下面介绍基于TCP协议的编程,其最主要的特点是建立完连接后才进行通信。常用的基于TCP网络编程的函数及功能如表9.3所示。

TCP Socket 编程

  服务器调用 socket()、bind()、listen() 完成初始化后,调用 accept() 阻塞等待,处于监听端口的状态;客户端调用socket()初始化后,调用connect()发出同步信号SYN, 井阻塞等待服务器应答,服务器应答一个同步-应答信号 SYN-ACK,客户端收到后从 connect() 返回,同时应答一个ACK, 服务器收到后从accept()返回。服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。

  建立连接后,TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。因此,服务器从accept()返回后立刻调用read(),通过传入 socket() 函数返回 socket 文件描述符进行数据读取,读socket就像读管道一样,如果没有数据到达就阻塞等待,这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答,服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求,客户端收到后从read()返回,发送下一条请求,如此循环下去。

  如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的 read()读取数据的时候就会读取到EOF,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。当任何一方调用close()后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown()则连接处于半关闭状态,仍可接收对方发来的数据。

listen 时候参数 backlog 的意义?

Linux内核中会维护两个队列:

  • 半连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态;
  • 全连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;

 SYN 队列 与 Accpet 队列

int listen (int socketfd, int backlog)
  • 参数一 socketfd 为 socketfd 文件描述符
  • 参数二 backlog,这参数在历史版本有一定的变化

在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。

在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。

但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。

想详细了解 TCP 半连接队列和全连接队列,可以看这篇:TCP 半连接队列和全连接队列满了会发生什么?又该如何应对?

accept 发生在三次握⼿的哪⼀步?

TCP 建立连接时与 Socket 的关系如下:

  • 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 client_isn,客户端进⼊SYN_SENT 状态;
  • 服务器端的协议栈收到这个包之后,和客户端进⾏ ACK 应答,应答的值为 client_isn+1,表示对 SYN 包client_isn 的确认,同时服务器也发送⼀个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务器端进⼊ SYN_RCVD 状态;
  • 客户端协议栈收到 ACK 之后,使得应⽤程序从 connect 调⽤返回,表示客户端到服务器端的单向连接建⽴成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进⾏应答,应答数据为server_isn+1;
  • 应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调⽤返回,这个时候服务器端到客户端的单向连接也建⽴成功,服务器端也进⼊ ESTABLISHED 状态。

从上⾯的描述过程,我们可以得知客户端 connect 成功返回是在第⼆次握⼿,服务端 accept 成功返回是在三次握⼿成功之后。

客户端调⽤ close 了,连接时断开的流程是什么?

TCP 释放连接时与 Socket 的关系如下:

  • 客户端调⽤ close ,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报⽂,进⼊ FIN_WAIT_1状态;
  • 服务端接收到了 FIN 报⽂,TCP 协议栈会为 FIN 包插⼊⼀个⽂件结束符 EOF 到接收缓冲区中,应⽤程序可以通过 read 调⽤来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再⽆额外数据到达。此时,服务端进⼊CLOSE_WAIT 状态;
  • 接着,当处理完数据后,⾃然就会读到 EOF ,于是也调⽤ close 关闭它的套接字,这会使得客户端会发出⼀个 FIN 包,之后处于 LAST_ACK 状态;
  • 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进⼊ TIME_WAIT 状态;
  • 服务端收到 ACK 确认包后,就进⼊了最后的 CLOSE 状态;
  • 客户端经过 2MSL 时间之后,也进⼊ CLOSE 状态;

上面两张图的详细解读来自:《图解网络v3.0》小林coding

例子

分别编写服务器端、客户端程序,服务器通过socket连接后,在服务器上显示客户端的IP地址或域名,从客户端读字符,然后将每个字符转换为大写并回送给客户端。客户端发送字符串“连接上了”,客户端把接收到的字符串显示在屏幕上。

分析

首先调用socket()函数创建一个socket,接着调用bind函数将其与本机地址以及一个本地端口号绑定,然后调用函数listen在相应的socket端口上监听,当accept接收到一个连接服务请求时,将生成一个新的socket套接口描述符。利用此套接口服务器接收并显示该客户机的域名或IP地址,并通过新的socket向客户端发送字符串“连接上了",最后关闭该 socket。
流程图如图9.3所示。

程序中的主要语句说明如下。

服务端

  1. 建立socket。建立 socket, 程序可以调用 socket 函数,该函数返回一个类似于文件描述符的句柄(下文的sockfd),可以使用read和write函数通过sockfd对文件进行读写。同时也意味着为一个socket数据结构分配存储空间。用语句实现:
    socket(AF_INET, S()CK_STREAM, 0);
    其中,参数AF_INET 表示采用IPv4协议进行通信;参数SOCK_STREAM 表示采用流式socket, 即TCP。

  2. 绑定bind。服务器需要调用bind 绑定一个网络IP地址和端口号,下列语句作用将参数sockfd和myaddr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听myaddr所描述的地址和端口号。bind()绑定成功返回0,失败返回-1,随后就可以在该端口监听服务请求。用语句实现:
    bind(sockfd, (struct sockaddr *)&my_addr,sizeof(struct sockaddr);
    其中,参数my_addr表示指向包含有本机IP地址及端口号等信息。

  3. 初始化端口。struct sockaddr* 是一个通用指针类型 ,myaddr 参数实际上可以接收多种协议的sockaddr结构体 ,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。在程序中对myaddr参数进行初始化的:

    bzero(&(my_addr.sin_zero),8);
    my_addr.sin_family = AF_INET;
    my_addr.sin_port = htons(SERVPORT);
    my_addr.sin_addr.s_addr = INADDR_ANY;
    

  首先将整个结构体清零,然后设置地址类型为AF_INET, 网络地址为INADDR_ANY, 这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个 IP 地址,端口号为 SERV_PORT,在程序中定义为 3333。

  1. 建立监听 listen。使socket处于被动的监听模式,并为该socket建立一个输入数据队列。将到达的服务请求保存在此队列中,直到程序处理它们。用语句实现:
    listen(sockfd, BACKLOG);
    其中,参数 BACKLOG 表示最大连接数。

  2. 响应客户端的请求。用函数accept生成一个新的套接口描述符,让服务器接收客户的连接请求。用语句实现:
    accept(sockfd, (struct sockaddr *)&remote_addr,&sin_size);
    其中,参数remote_addr用于接收客户端地址信息;参数sin_size用于存放地址的长度。
    服务器调用accept()接收连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。remote_addr是一个传入参数,accept()同时接收客户端的地址和端口号。sin_size传人参数是调用者提供的缓冲区,sin_size的长度以避免缓冲区溢出问题。如果给sin_size 参数传NULL,表示不关心客户端的地址。

  3. 发送数据 send。该函数用于面向连接的socket上进行数据发送。用语句实现:
    send(client_fd, buf,len, 0);
    其中,buf为发送字符串的内存地址,fen表示发送字符串的长度。

  4. 关闭 close。停止在该socket上的任何数据操作。用语句实现:
    close(client_fd);

客户端

  1. 建立 socket。建立socket,程序可以调用socket函数,该函数返回一个类似千文件描述符的句柄,同时也意味若为一个socket数据结构分配存储空间。用语句实现:
    socket(AF_INET, SOCK_STREAM, 0)

  2. 请求连接 connect。启动和远端主机的直接连接。用语句实现:
    connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(struct sockaddr));

  3. 接收数据 recv。该函数用于面向连接的socket上进行数据接收。用语句实现:
    recv(sockfd, buf, MAXDATASIZE, 0);

  4. 关闭 close。停止在该socket上的任何数据操作。用语句实现:
    close(sockfd);

代码实现

后续代码的具体实现,以及基于 UDP 的网络编程请自行查阅《Linux程序设计》,金国庆等,浙江大学出版社,第9章的内容。

参考文章

《Linux程序设计》,金国庆等,浙江大学出版社,本文内容搬运自第9章
4.1 TCP 三次握手与四次挥手面试题 | 小林coding
socket图解 · Go语言中文文档 (topgoer.com)

posted @ 2021-11-27 10:19  拾月凄辰  阅读(3858)  评论(0编辑  收藏  举报