今天只是想浅谈一下对于Linux网络编程中一些基本问题的理解。我们知道互联网通信都是基于TCP/IP协议簇的,里面从一开始设计就保证了基本的通信安全和效率问题。 顾名思义的解释:IP(Internet协议)和TCP(传输控制协议),合起来叫TCP/IP。
IP协议为接入网络中的每台计算机分配了一个独一无二的地址,并负责在传输过程中寻找到目的计算机。TCP协议则负责保证传输的可靠性:一旦传输中发现问题,该协议就会发出信号要求重新传输相关的数据直到所有数据安全正确地传输到目的地为止。为了验证TCP/IP在超远距离传输上的可靠性,曾经对于TCP/IP贡献比较大的两个人瑟夫和卡恩为了验证传输的可靠性还进行了一个著名的试验。他们设计了一个长达9.4万公里的路径,使数据包先后通过点对点卫星网络、陆地电缆和卫星间网络,并贯串了欧洲和美国的几乎所有电脑系统。最后,数据包完整地回到了实验室。1974年,美国国防部(开始使用的是阿帕网)决定无条件公布TCP/IP的核心技术,网络发展高潮因此迅速到来。
下面我们说说数据是怎么在网路中传送的:
我们知道现在的互联网中使用的TCP/IP协议是基于OSI(开放系统互联)的七层参考模型的,从上到下分别为 应用层、 表示层、 会话层 、传输层 、网络层 、数据链路层和物理层。其中数据链路层又可是分为两个子层分别为逻辑链路控制层(Logic Link Control,LLC )和介质访问控制层((Media Access Control,MAC )也就是平常说的MAC层。LLC对两个节点中的链路进行初始化,防止连接中断,保持可靠的通信。MAC层用来检验包含在每个桢中的地址信息。
现在我们讨论一下数据在同一个网段内的传递情况,不同网段姑且先不说。假设有两台电脑分别命名为A和B,A需要相B发送数据的话,A主机首先把目标设备B的IP地址与自己的子网掩码进行“与”操作,以判断目标设备与自己是否位于同一网段内。如果目标设备在同一网段内,并且A没有获得与目标设备B的IP地址相对应的MAC地址信息,则源设备(A)以第二层广播的形式(目标MAC地址为全1)发送ARP请求报文,在ARP请求报文中包含了源设备(A)与目标设备(B)的IP地址。同一网段中的所有其他设备都可以收到并分析这个ARP请求报文,如果某设备发现报文中的目标IP地址与自己的IP地址相同,则它向源设备发回ARP响应报文,通过该报文使源设备获得目标设备的MAC地址信息。为了减少广播量,网络设备通过ARP表在缓存中保存IP与MAC地址的映射信息。在一次 ARP的请求与响应过程中,通信双方都把对方的MAC地址与IP地址的对应关系保存在各自的ARP表中,以在后续的通信中使用。ARP表使用老化机制,删除在一段时间内没有使用过的IP与MAC地址的映射关系。网上找了一张拓扑结构的图:
如果中间要经过交换机的话,根据交换机的原理,它是直接将数据发送到相应端口,那么就必须保有一个数据库,包含所有端口所连网卡的MAC地址。它通过分析Ethernet包的包头信息(其中包含不原MAC地址,目标MAC地址,信息的长度等信息),取得目标B的MAC地址后,查找交换机中存储的地址对照表,(MAC地址对应的端口),确认具有此MAC地址的网卡连接在哪个端口上,然后将数据包发送到这个对应的端口,也就相应的发送到目标主机B上。这样一来,即使某台主机盗用了这个IP地址,但由于他没有这个MAC地址,因此也不会收到数据包。
当我们知道数据在网络链路中的传递后,现在我们可以看看TCP/IP的模型了:
TCP/IP协议同ISO/OSI模型一样,也可以安排成栈形式。但这个栈不同于ISO/OSI版本,比ISO/OSI栈少,所以又称之为短栈。另外,需要知道的是:TCP/IP协议栈只是许多支持ISO/OSI分层模型协议栈的一种,是一个具体的协议栈。
对于TCP/IP协议栈划分,大部分描述都假定它占据了协议结构的4到5个功能层。基于4层的TCP/IP协议栈最具说服力的是:这一观点是由TCP/IP原始标准的创立者——美国国防部提出的,它与ISO/OSI参考模型的对应关系如下图:
关于4层中 每一层在干什么就不详细说了,我也记不住,这种问题搜索一下直接明了,就不废话了。
但是话说回来对于进行网络编程的人必须清楚这一点:当用户启动了某个应用层服务之后,该应用要首先建立一个到某特定机器的连接,随后告诉目的机器本次连接的操作意图,并控制整个操作过程。在源计算机系统中,应用层将要发送的信息交给服务层的TCP(或UDP),TCP通过增加TCP协议所规定的报头来确保信息正确地传输到目的地。然后,TCP把包括目的地址在内的报头和用户数据交给网络互连层的IP,IP负责为信息选择路由。由于 TCP和IP处理与网络有关的细节,所以应用层协议可以将一个网络连接看成一个简单的字节流,而不必描述与通信有关的任何细节。
下面是TCP/IP每层对应的一些我们熟知的服务:
再此我们熟悉了TCP/IP这种网路协议后,我们如果要搞清楚网络编程是怎么进行的就必须要知道在网际间进程是如何通信,我们从上面的内容中可以了解到计算机通信,或者说是网际间的进程通信其实都是基于TCP/IP的,而socket正是实现了这种协议,我们知道IP可以唯一表示一台主机在网路中的地址(有时需要MAC),然而TCP/IP提供了一组协议簇,再加上我们的一个开放的通道就可以直接实现通信了,这个通道其实就是我们平时经常说的端口,对于计算机的端口,其实说白了就是一中抽象的软件数据结构,并包含自己的I/O缓冲区,可以当作数据的收发接口。对于计算机本身,端口的值是一个整形数据,其大小就不言而喻了。从0-65535,中区分了三种端口,有些是系统预订的,有些事某项服务默认的(比如阿帕奇的web端口默认为80,ssh服务的端口为22),我们一般建议在4000-10000(并不是准确数据,我自己就是那样做的)之间的端口自己随意选择去开启服务。有了端口的概念我们就可以用这样的三元组(ip+协议+端口)去描述一个完整的网际间的进程通信了。
下面我们说说Socket究竟是什么?我们知道在Linux界有一句很出名的话就是“everything is file”,一切都是文件,在Linux中将一切设备都抽象为文件,当然socket也不会例外,这就决定了socket本身就可以实现类似于一般文件的I/O操作,open(create)、write/read、close。
socket本身就是一个描述文件,存在struct socket这个数据类型。我们经常见得就是三中套接字:
(1)流式套接字(SOCK_STREAM),它是一种面向连接的套接字,对应于TCP应用程序。
(2)数据报套接字(SOCK_DGRAM),它是一种无连接的套接字,对应于的UDP应用程序。
(3)原始套接字(SOCK_RAW),它是一种对原始网络报文进行处理的套接字。
流式套接字(SOCK_STREAM)和数据报套接字(SOCK_DGRAM)涵盖了一般应用层次的TCP/IP应用。
原始套接字的创建使用与通用的套接字创建的方法是一致的,只是在套接字类型的选项上使用的是另一个SOCK_RAW。在使用socket函数进行函数创建完毕的时候,还要进行套接字数据中格式类型的指定,设置从套接字中可以接收到的网络数据格式
下面就说说针对socket的一些操作函数吧。
「一」:socket()
int socket(int domain, int type, int protocol);
socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
正如可以给fopen的传入不同参数值,以打开不同的文件。创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:
- domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
- type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等(socket的类型有哪些?)。
- protocol:故名思意,就是指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。。
注意:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。
当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。
「二」:bind()
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函数的三个参数分别为:
- sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
- addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,
- addrlen:对应的是地址的长度
这里插入一段,关于我们一般在TCP连接是建立的网络地址结构,一般以struct sockaddr_in 结构来表示,在设定它的参数时,其中设置IP或是端口信息时可以通过系统提供的htonl,htons等等API完成,这里注意的问题其实就是网络字节与主机字节的区别:
主机字节序就是我们平常说的大端和小端模式:不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。引用标准的Big-Endian和Little-Endian的定义如下:
a) Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
b) Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。
网络字节序:4个字节的32 bit值以下面的次序传输:首先是0~7bit,其次8~15bit,然后16~23bit,最后是24~31bit。这种传输次序称作大端字节序。由于TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种次序,因此它又称作网络字节序。字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,一个字节的数据没有顺序的问题了。「三」listen()、connect()
int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。关于listen()的第二个参数backlog,4.2BSD手册对它的定义是:由为处理连接(处于SYN_RCVD状态)构成的队列可能增长到的最大长度。有一点必须清楚,就是内核为任意一个给定的监听套接字维护两个队列:1.未完成连接队列,这些套接字处于SYN_RCVD状态。2.已完成连接队列,这里的套接字处于ESTABLISHED状态。
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。
「四」:accept()
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。
注意:accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。
「五」:对于socket的I/O操作函数
网络I/O操作有下面几组:
- read()/write()
- recv()/send()
- recvmsg()/sendmsg()
- recvfrom()/sendto()
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
这几个I/O函数就不意义介绍了,百度一大堆。
「六」:close()
#include <unistd.h>
int close(int fd);
close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。
注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
大概就是这样,当然像TCP三次握手就发生在client端以connect函数连接server端时,与accpet函数进行了我们所谓的三次握手。
- 客户端向服务器发送一个SYN J
- 服务器向客户端响应一个SYN K,并对SYN J进行确认ACK J+1
- 客户端再想服务器发一个确认ACK K+1
-
某个应用进程首先调用close主动关闭连接,这时TCP发送一个FIN M;
-
另一端接收到FIN M之后,执行被动关闭,对这个FIN进行确认。它的接收也作为文件结束符传递给应用进程,因为FIN的接收意味着应用进程在相应的连接上再也接收不到额外数据;
-
一段时间之后,接收到文件结束符的应用进程调用close关闭它的socket。这导致它的TCP也发送一个FIN N;
-
接收到这个FIN的源发送端TCP对它进行确认。
这里还需要区分的就是阻塞套接字和非阻塞套接字在数据处理收发上是有一定区别的,每一个TCP套接口有一个发送缓冲区,可以用SO_SNDBUF套接口选项来改变这个缓冲区的大小。当应用进程调用 write时,内核从应用进程的缓冲区中拷贝所有数据到套接口的发送缓冲区。如果套接口的发送缓冲区容不下应用程序的所有数据(或是应用进程的缓冲区大于套接口发送缓冲区,或是套接口发送缓冲区还有其他数据),应用进程将被挂起(睡眠)。这里假设套接口是阻塞的,这是通常的缺省设置。内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都拷贝到套接口发送缓冲区。因此从写一个TCP套接口的write调用成功返回仅仅表示我们可以重新使用应用进程的缓冲区。它并不告诉我们对端的 TCP或应用进程已经接收了数据。
TCP取套接口发送缓冲区的数据并把它发送给对端TCP,其过程基于TCP数据传输的所有规则。对端TCP必须确认收到的数据,只有收到对端的ACK,本端TCP才能删除套接口发送缓冲区中已经确认的数据。TCP必须保留数据拷贝直到对端确认为止。
1 输入操作: read、readv、recv、recvfrom、recvmsg 。如果某个进程对一个阻塞的TCP套接口调用这些输入函数之一,而且该套接口的接收缓冲区中没有数据可读,该进程将被投入睡眠,直到到达一些数据。既然 TCP是字节流协议,该进程的唤醒就是只要到达一些数据:这些数据既可能是单个字节,也可以是一个完整的TCP分节中的数据。如果想等到某个固定数目的数据可读为止,可以调用readn函数,或者指定MSG_WAITALL标志。既然UDP是数据报协议,如果一个阻塞的UDP套接口的接收缓冲区为空,对它调用输入函数的进程将被投入睡眠,直到到达一个UDP数据报。
对于非阻塞的套接口,如果输入操作不能被满足(对于TCP套接口即至少有一个字节的数据可读,对于UDP套接口即有一个完整的数据报可读),相应调用将立即返回一个EWOULDBLOCK错误。
2 输出操作:write、writev、send、sendto、sendmsg
对于一个TCP套接口,内核将从应用进程的缓冲区到该套接口的发送缓冲区拷贝数据。对于阻塞的套接口,如果其发送缓冲区中没有空间,进程将被投入睡眠,直到有空间为止。
对于一个非阻塞的TCP套接口,如果其发送缓冲区中根本没有空间,输出函数调用将立即返回一个EWOULDBLOCK错误。如果其发送缓冲区中有一些空间,返回值将是内核能够拷贝到该缓冲区中的字节数。这个字节数也称为不足计数(short count)
UDP套接口不才能在真正的发送缓冲区。内核只是拷贝应用进程数据并把它沿协议栈向下传送,渐次冠以UDP头部和IP头部。因此对一个阻塞的UDP套接口,输出函数调用将不会因为与TCP套接口一样的原因而阻塞,不过有可能会因其他的原因而阻塞。
到这里我们其实将基本的Linux网络编程的基本内容简单说了一下,但是这只是刚刚开始,我们需要的是当我们在真真正正写一段服务代码时,我们怎么去处理这些连接,我们以什么机制或是策略去设计我们的服务代码的结构,这里就牵扯到了各种各样的网络编程模型,阻塞I/O,非阻塞I/O,同步、异步处理,多路I/O复用等等一系列实现策略,在编程模型上选择基本的迭代式服务器,还是简单的并发处理,或是以epoll这种高效的轮转机制代替基本的select I/O复用模型。或是以preforking这种预先建立进程/线程池的形式处理。等等一切的处理策略才是我们需要在写服务代码时考虑的内容,这就是为什么我觉得Linux网络编程是C中最难搞的原因了。