网络编程 --- TCP

网络编程 --- TCP

首先,我们得明确网络中进程是如何通信的。

我们知道本地的进程间通讯(IPC)有很多种方式,总结后可以分为以下四类:

  • 消息传递(管道,FIFO,消息队列)
  • 同步(互斥量,条件变量,读写锁,信号量)
  • 共享内存(匿名的和具名的)
  • 远程过程调用(它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。如TCP和UDP)

 

但是这些都不是这篇博客的重点,这篇博客重点在于网络中是如何通讯的,首先需要解决的是如何表示一个进程,在本地中我们可以使用PID来标识一个进程,但是在网络中PID是没有用的。幸好TCP/IP协议帮我们解决了这个问题,网络层的“IP地址”可以唯一标识一个主机,而传输层的“协议+端口”可以帮我们唯一标识主机中的一个应用程序(进程)。

这个样子就可以使用三叉戟(IP地址,协议,端口号)就可以标识网络中的进程了,网络中的所有通讯就可以利用这个三叉戟进行了。就目前而言,几乎所有的应用程序都是采用的socket,而现在又是网络时代,网络中的通信是无处不在的,这也就是为什么说“ 一切皆socket ”。

 

上面我们已经知道网络中的进程是用过socket来通讯的,那什么是socket呢?

socket起源于UNIX,而UNIX/Linux的一大特点就是“一切皆文件”,都可以用“打开open->读写write/read->close”模式来操作,那么我觉得可以将socket看作是一个特殊的文件,一些socket函数就是对其进行的操作。

 

那么我们现在来看一看TCP网络编程中如何对socket进行操作的。

服务器端编程流程如下:

①第一步:首先要创建一个socket套接字(其本质为一个文件描述符):

//相当于“买手机”

这个socket系统调用可以用于创建一个socket:

  • 第一个参数domain:告诉系统使用哪一个底层协议族。对于TCP/IP协议来说,该参数应该设置为PF_INET,或者PF_INET6(用于IPV6);不过由于AF_INET和PF_INET协议内容一致,所以它俩经常混用,PF_INET6类似。
  • 第二个参数type:指定服务类型,对于TCP/IP协议族而言,其值取SOCK_STREAM(字节流服务)表示传输层使用TCP协议,取SOCK_DGRAM(数据报服务)表示传输层使用UDP协议。
  • 第三个参数protocol:是在前两个参数构成的协议集合下,再选择一个具体的协议,不过这个值通常都是唯一的(前两个参数已经完全决定了它的值),所以几乎所有情况下,我们都把这个参数设置为0,表示使用默认协议。

代码:

 

②第二步:命名socket(将一个socket与socket地址绑定称为给socket命名)

//相当于“注册手机卡后给手机装卡”

这一步主要是由于我们创建socket的时候,只是给它指定了协议族,但是并未指定使用该地址族中的哪个具体的socket地址。

我们知道,在服务器中,通常要命名socket,因为只有命名后客户端才知道该如何连接它。

但是在客户端中,则通常不需要命名socket,而是采取匿名方式,即使用操作系统自动分配的socket地址。命名socket的系统调用是bind,其定义如下:

  • 第一个参数sockfd:上一步socket函数返回的文件描述符
  • 第二个参数my_addr:bind将my_addr所指的socket地址分配给未命名的sockfd文件描述符
  • 第三个参数addrlen:addrlen参数指出该socket地址的长度
  • bind函数成功时返回0,失败时返回-1并设置errno,其中两种常见的errno是EACCES和EADDRINUSE,他俩的含义分别是:        EACCES:被绑定的地址是受保护的地址,仅超级用户可以访问。   EADDRINUSE:被绑定的地址正在使用中。比如将socket绑定到了一个处于TIME_WAIT状态的socket地址上。

 

/*以下就是用来对应第二步标题的办卡装卡*/

bind系统调用第二个参数是一个struct sockaddr结构体的指针,存放的是地址族协议和socket地址值,但是由于这个结构体中存放socket地址值的变量太小,根本无法容纳多数协议族的地址值。所以Linux系统专门定义了新的专用socket地址结构体用来存储。

由于我们使用的是TCP/IP协议的IPV4,所以这里使用对应的专用socket地址结构体:

注意:所有专用socket地址类型的变量在实际使用时,都需要转化为通用socket地址类型sockaddr(强制转换即可),因为所有socket编程接口使用的地址参数的类型都是sockaddr。

 

那么为什么我们要将端口号以及IP地址转换为网络字节序才能使用呢?

答:如果在两台使用不同字节序的主机之间进行信息传递时,由于接收方不知道该使用大端还是小端去解析这个数据,必然会出现一系列错误。所以为了避免这种情况的发生,我们统一将要发送的数据转化为大端字节序(网络字节序)数据后,再进行发送,这样接收方就可以根据自身情况选择具体的大小端进行解析了。

需要指出的是,哪怕是一台主机上的两个进程(比如一个由C语言编写,另一个由java编写)通讯,也要考虑字节序的问题(java虚拟机采用的是大端字节序)。

所以在这里,Linux提供了4个函数来完成(主机字节序)和(网络字节序)之间的转换:

他们的含义很明确,比如htonl表示“host to network long”,即将长整形的主机字节序数据转换为网络字节序数据。这4个函数中,长整形数据通常用来转换IP地址,短整型数据用来转换端口号。

 

IP地址转换函数:

通常,人们习惯用可读性好的字符串来表示IP地址,比如用点分十进制字符串表示IPV4地址,以及用十六进制字符串表示IPV6,但编程中我们需要将其转化为整数(二进制)才能使用,而记录日志时则刚好相反,我们要把整数表示的IP地址转换为可读的字符串。

下面的3个函数可用于 (点分十进制字符串表示的IPV4地址)和(用网络字节序整数表示的IPV4地址)之间的转换:

  • 第一个inet_addr函数:将用点分十进制字符串表示的IPV4地址 ---> 用网络字节序整数表示的IPV4地址。
  • 第二个inet_aton函数:与第一个函数完成同样的功能,但是将转换结果存储于参数inp指向的地址结构中,成功返回1,失败则返回0。

 

  • 第三个inet_ntoa函数:将用网络字节序整数表述的IPV4地址 ---> 用点分十进制字符串表示的IPV4地址。但需要注意的是,这个函数内部使用的是一个静态变量来保存转化结果,函数的返回值指向该静态内存,因此,这个函数是不可重入的。

代码清单5-2可以证明:

 

创建socket地址的代码(上面标题提到的注册手机卡):

 

/*而这里就是办好手机卡之后,用bind系统调用来给手机装卡的*/

代码:

 

③第三步:监听socket,即创建一个监听队列

//相当于“开启手机”

socket被命名之后,还不能马上接收客户连接,我们需要使用如下的系统调用来创建一个监听队列以存放待处理的客户连接:

listen系统调用成功时返回0,失败时则返回 -1,并设置errno。

  • 第一个参数sockfd:为准备被监听的socket
  • 第二个参数backlog:为内核给维护的监听队列的最大长度。监听队列的长度如果超过backlog,服务器将不受理新的客户连接,客户端也将受到ECONNREFUSED错误信息。

需要注意的是这里的最大长度指的是处于完全连接状态(完成了三次握手)的socket上限,不算正在与服务器对话的客户端(这个连接已经从监听队列中取出来了)。

如果backlog传入5,则内核维护的监听队列的最大长度为6(size + 1)。那么第七个连接服务器的客户端进程则处于SYN_RECV状态(半连接状状态),指的是三次握手,已完成了两次,再进一步接收到客户端的ACK(确定信息)就进入了客户端ESTABLISHED(已完成连接)状态了。

也就是客户端发送了SYN请求连接信息,服务器端接收到了,也发送了回馈信息,但是这个监听队列没有多余的空间了,所以只能处于SYN_RECV状态,如果过了一点点时间,系统发现第七个连接还是处于半连接状态,则自动将其断掉。

代码:

 

④第四步:接受连接(accept系统调用从listen监听队列中接受一个连接)

//相当于“接听电话”

  • 第一个socket参数:是执行过listen系统调用的监听socket
  • 第二个addr参数:用来获取接受连接的远端socket地址
  • 第三个addrlen参数:该socket地址的长度由addrlen参数值出(需要注意的是这里传入长度时需要的是一个指针,而bind系统调用时,传入长度时需要的是一个整型值)
  • accept系统调用成功时返回一个新的连接socket,该socket唯一地标识了被接受的这个连接,服务器可通过该socket与被接受连接对应的客户端进行通讯。accept调用失败时返回 -1并设置errno。

代码:这里服务器与一个客户端结束交互时,要接着与监听队列中下一个客户端进行交互,所以这里要写到while循环中。

 

问题:

代码就不截图了,参考书:Linux高性能服务器编程中第五章内容

结论:accept只是从监听队列中取出连接,而不论连接处于何种状态(如ESTABLISHED状态以及CLOSE_WAIT状态),更不关心任何网络状况的变化。

 

⑤第五步:TCP数据读写(通过recv/send收发数据)

//相当于“进行通话”

对文件的读写操作read/write同样适用于socket。但是socket编程接口提供了几个专门用于socket数据读写的系统调用,他们增加了对数据读写的控制。

其中用于TCP流数据读写的系统调用是:

  • recv读取sockfd上的数据,buff和len参数分别指定读缓冲区的位置和大小,flags参数为数据收发提供了额外的控制,通常设置为0即可。recv成功时返回实际读取到的数据的长度,它可能小于我们期望的长度len,因此我们可能要多次调用recv,才能读取到完整的数据,所以写到while循环内。recv可能返回0,这意味着通信对方已经关闭连接了。recv出错时返回 -1,并设置errno。
  • send往sockfd上写入数据,buff和len参数分别指定写缓冲区的位置和大小。send成功时返回实际写入的数据的长度,失败则返回 -1,并设置errno。

flags参数的可选值见下表:

 

代码:因为接收数据时有可能一次也读不完,所以要写到while循环内,进行多次读取。

 

第六步:关闭连接(服务器端要关闭sockfd描述符以及accept返回的描述符)

//相当于“挂电话以及关机”

关闭一个连接实际上就是关闭该连接对应的socket,这可以通过如下关闭普通文件描述符的系统调用来完成:

参数fd是待关闭的socket。不过,close系统调用并非是立即关闭一个连接,而是将fd的引用计数 -1,只有当fd的引用计数为0时,才真正关闭连接。多进程程序中,一次fork系统调用默认将父进程中打开的socket的引用计数 +1,因此我们必须在父进程和子进程中都对该socket执行close调用才能将此连接关闭。

如果无论如何都要立即终止连接(而不是将socket的引用计数 -1),则可以使用如下的shutdown系统调用(相对于close来说,它是专门为网络编程设计的):

sockfd参数是待关闭的socket,howto参数决定了shutdown的行为,它可取表中的某个值:

由此可见,shutdown可以分别关闭socket上的读和写,或者都关闭。而close在关闭连接时只能将socket上的读和写同时关闭。

shutdown成功时返回0,失败则返回 -1,并设置errno。

 

 

客户端编程流程如下:

具体实现与服务器端大同小异,不过bind这一步可有可无,这是因为服务器端我们通常要命名socket,因为只有命名后客户端才知道该如何连接它。但是在客户端中,则通常不需要命名socket,而是采取匿名方式,即使用操作系统自动分配的socket地址。

就像给110打电话,110作为服务器端,你手机装没装手机卡,都可以直接给110打紧急急救电话,所以这里采用匿名方式,即使用操作系统自动分配的socket地址。

 

第一步:创建socket,与TCP大致一致

第二步:命名socket,一般不写这步,由操作系统自动给分配socket地址。

第三步:主动发起连接,connect();

如果说服务器端listen调用来被动接受连接,那么客户端需要通过如下的系统调用来主动与服务器建立连接:

  • 第一个参数sockfd:由socket系统调用返回一个socket
  • 第二个参数serv_addr:是服务器监听的socket地址
  • 第三个参数addrlen:是指定这个地址的长度
  • connect成功时返回0。一旦成功建立连接,sockfd就唯一地标识了这个连接,客户端就可以通过sockfd来与服务器通讯。connect失败则返回 -1,并设置errno。
  • 其中两中常见的errno是ECONNREFUSED和ETIMEDOUT,他们的含义如下:ECONNREFUSED:目标端口不存在,连接被拒绝。   ETIMEDOUT:连接超时

第四步:TCP数据读写(通过recv/send收发数据),与TCP大致一致

第五步:关闭连接(因为客户端没有accept这一步,所以只需要关闭sockfd即可),与TCP大致一致

 

至此,TCP网络编程完毕。

 

猜你喜欢

转载自blog.csdn.net/IT_Quanwudi/article/details/86527630