初次在项目中接触到Socket编程,connect、bind、acccept、listen、recv等各个函数能用起来,但是只知其然不知其所以然。拜读《TCP/IP Sockets编程C语言实现》,小有收获,总结如下。
术语
主机(host和路由器(router)通过通信信道互联形成计算机网络。路由器也称为网关(gateway),其作用是把信息(information)从一条信道中继或转发(forward)到另一条信道。信息的含义是指由程序(通常运行在主机上)构造和解释的字节序列。在计算机网络环境中,这些字节序列称为分组(packet)。
协议(protocol)是关于由程序交换的分组及其含义的协定,协议说明如何构造分组以及如何解释信息。协议通常设计成使用给定的能力解决特定的问题。实现一个有用的网络需要解决大量不同的问题,为了保持事情易于管理并且是模块化的,设计了不同的协议来解决不同的问题集。TCP/IP就是这样一个解决方案的集合,也称之为协议族(protocol suite)。TCP/IP协议族中的主要协议是IP(Internet Protocol,网际协议),TCP(Transmission Control Protocol,传输控制协议)和UDP(User Datagram Protocol,用户数据报协议)。
从上图可以看出,应用程序通过Sockets API访问UDP和TCP提供的服务。TCP/UDP使用端口号(Port Number)来确定主机内的应用程序。
将IPV4地址写为一组4个用句点隔开的十进制数字(例如192.168.0.1),这称为点分四组(dotted-quad)表示法。以10或192.168等开头的IP地址都是特殊地址,这些地址不能从全球Internet访问到它们。当使用名称标识通信端点时,系统将做一些额外的工作把名称解析为地址。名称解析服务可以访问广泛来源的信息,两种主要来源是DNS(Domain Name System,域名系统)和本地配置数据库。
套接字(socket)是一个抽象层,应用程序可以通过它发送和接收数据,其方式与打开文件句柄允许应用程序读、写数据到稳定的存储器非常相似。TCP/IP套接字是由Internet地址、端到端协议(TCP或UDP)、端口号唯一标识的。以前说端口标识主机上的应用程序,实际上,端口标识主机上的套接字。
sockaddr结构是Sockets API的一种泛型数据类型。对于IPv4,使用sockaddr_in结构,它只是sockaddr结构中数据的更详细的试图,是为IPv4套接字为定制的,可以把它强制转换为sockaddr。对于IPv6,使用sockaddr_in6结构。泛型sockaddr其实装不下IPv6的地址,于是创建了sockaddr_storage结构。
一些概念
把消息传送到程序需要用到两段地址,即IP地址和端口号。类比邮政业务进行理解,其中IP地址就是街道地址,而端口号对应于房间号。类比电话系统,IP地址就是公司主机电话号码,端口号就是某个人的电话分机号。
在服务器-客户端的模式中,类比电话及邮政系统,客户(client)程序发起通信,服务器(server)程序被动的等待。客服必须给connect()提供服务器的地址,而服务器不需要知道对应方(peer)的地址,但服务器必须给bind()指定它自己的地址。
TCP是一种字节流协议。这类协议的一种实现是不会保持send()的边界。通过在连接的一端调用send()发送的字节可能不会通过在另一端单独调用一次recv()而全都返回。因此需要反复接收此字节,直至接收到的字节数与发送的字节数相等为止。这个循环多半只会执行一次,但是这不能保证,编写套接字程序的基本原则是:对于另一端的网络和程序将要做什么事情,永远都不能做出假设这也是使用套接字的网络应用程序的一个关键原则是防御性编程:你的代码绝对不能对通过网络接收到的任何信息作出假定。
书中提供的示例没有包含专门用于从错误中恢复的大量代码,只是简单的终止程序并退出。生产代码不应该如此轻易地放弃。
偶尔需要给用户提供信息,如果需要格式化能力,就使用printf(),否则,就使用fputs()。应该尽量避免使用printf()输出固定的、预先格式化的字符串。你永远也不应该做的一件事是:把从网络接收到的文本作为第一个参数传递给printf()。它会引起严重的安全性问题,要代之以使用fputs()。
recv()会阻塞到接收到数据或者客户关闭连接为止。当客户正常地关闭连接时,recv()返回0。如果从客户发送的数据包或者来自服务器的应答数据报丢失,我们的应答客户将会无限期地阻塞。这种情况需要使用recvfrom()解除阻塞。
accept()会阻塞,直至建立了连接以侦听套接字的端口号为止,此时,accept()为新套接字返回一个描述符。
close()告诉底层协议栈发起关闭通信以及释放与套接字关联的任何资源所需的任何动作。一旦调用close(),就会对导致错误的套接字调用其它操作,例如send()和recv()。
Socket底层的理解
如果不理解套接字的具体实现所关联的数据结构和底层协议的工作细节,就很难领会网络编程的一些精妙之处。只是简略对一些普通事件序列的基础性的理解,对于编程也是有用的。
- 底层数据结构
RecvQ:等待递送的所接收数据的FIFO队列。
SendQ:等待传输的数据的FIFO队列。
State:Listening、TIME_WAIT、ESTABLISHED、Closed、Connecting、Syn_Sent…
由于状态之间的过渡非常迅速,很难在netstat提供的“快照”中捕获部分中间状态。执行套接字涉及的函数,都会导致状态的变化,更详细的内容可以查看“TCP套接字的生存期”。
- send()与recv()时底层的状态的变化
在TCP套接字上完成send()调用并不意味这实际地传输了数据,而只是把它们复制到了本地缓冲区SendQ中。
TCP协议负责移动字节,从SendQ中移动到RecvQ中,这种转移不受用户程序控制。
通过调用Recv()可以把字节从RecvQ中移动到Delivered中,传输的块的大小依赖于RecvQ中的数据量以及提供给recv()的缓冲区的大小。
- 避免死锁
设计套接字应用程序,必须小心地避免死锁(deadlock),在这种状态中,每一方都被阻塞,并等待另一方执行某种动作。例如,在建立连接后客户和服务端都立即尝试接收数据,则两端都阻塞在Recv(),导致死锁。
如上图所示,此时RecvQ和SendQ缓冲区都是500字节,两端都尝试发送1500字节的数据,此时两端都阻塞在了send()中(因为应用层都还有500字节没有送到SendQ中)。
考虑到如上这些情况,需要仔细设计协议,尤其是要避免两个方向上同时发送大量的数据。