IP socket 是在其上建立高级Internet 协议的最低级的层:从HTTP到SSL到POP3到Kerberos再到UDP-Time,每种Internet协议都建立在它的基础上。为了实现自定义的协议,或定制众所周知的协议的实现,程序员需要掌握基本的socket基础结构的工作知识。
1、网络进程间的通信--Socket 我们知道本地的进程间通信(IPC)有很多种方式,通常可以总结为下面4类:
* 消息传递(管道、FIFO、消息队列)
* 同步(互斥量、条件变量、读写锁、文件和写记录锁、信号量)
* 共享内存(匿名的和具名的)
* 远程过程调用(Solaris门和Sun RPC)
那网络中的进程之间是如何通信的呢?例如我们每天打开浏览器浏览网页时,浏览器的进程怎么与web服务器通信的?当你用QQ聊天时,QQ进程怎么与服务器或你好友所在的QQ进程通信?首要解决的问题是如何唯一标识一个进程,否则通信无从谈起!在本地可以通过进程PID来唯一标识一个进程,但是在网络中这是行不通的。其实TCP/IP协议族已经帮我们解决了这个问题,网络层的“ip地址”可以唯一标识网络中的主机,而传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。这样利用三元组(ip地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互。
使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字(socket)和UNIX System V的TLI(已经被淘汰),来实现网络进程之间的通信。就目前而言,几乎所有的应用程序都是采用socket,而现在又是网络时代,网络中进程通信是无处不在,所以说“一切皆socket”。
(1) IP、TCP和UDP
当您编写socket应用程序的时候,您可以在使用TCP还是使用UDP之间做出选择。它们都有各自的优点和缺点。
TCP是流协议,而UDP是数据报协议。换句话说,TCP在客户机和服务器之间建立持续的开放连接,在该连接的生命期内,字节可以通过该连接写出(并且保证顺序正确)。然而,通过 TCP 写出的字节没有内置的结构,所以需要高层协议在被传输的字节流内部分隔数据记录和字段。
另一方面,UDP不需要在客户机和服务器之间建立连接,它只是在地址之间传输报文。UDP的一个很好特性在于它的包是自分隔的(self-delimiting),也就是一个数据报都准确地指出它的开始和结束位置。然而,UDP的一个可能的缺点在于,它不保证包将会按顺序到达,甚至根本就不保证。当然,建立在UDP之上的高层协议可能会提供握手和确认功能。
对于理解TCP和UDP之间的区别来说,一个有用的类比就是电话呼叫和邮寄信件之间的区别。在呼叫者用铃声通知接收者,并且接收者拿起听筒之前,电话呼叫不是活动的。只要没有一方挂断,该电话信道就保持活动,但是在通话期间,他们可以自由地想说多少就说多少。来自任何一方的谈话都按临时的顺序发生。另一方面,当你发一封信的时候,邮局在投递时既不对接收方是否存在作任何保证,也不对信件投递将花多长时间做出有力保证。接收方可能按与信件的发送顺序不同的顺序接收不同的信件,并且发送方也可能在他们发送信件是交替地接收邮件。与(理想的)邮政服务不同,无法送达的信件总是被送到死信办公室处理,而不再返回给发送。
(2)对等方、端口、名称和地址
除了TCP和UDP协议以外,通信一方(客户机或者服务器)还需要知道的关于与之通信的对方机器的两件事情:IP地址或者端口。IP地址是一个32位的数据值,为了人们好记,一般用圆点分开的4组数字的形式来表示,比如:64.41.64.172。端口是一个16位的数据值,通常被简单地表示为一个小于65536的数字。大多数情况下,该值介于10到100的范围内。一个IP地址获取送到某台机器的一个数据包,而一个端口让机器决定将该数据包交给哪个进程/服务(如果有的话)。这种解释略显简单,但基本思路是正确的。
上面的描述几乎都是正确的,但它也遗漏了一些东西。大多数时候,当人们考虑Internet主机(对等方)时,我们都不会记忆诸如64.41.64.172这样的数字,而是记忆诸如gnosis.cx这样的名称。为了找到与某个特定主机名称相关联的IP地址,一般都使用域名服务器(DNS),但是有时会首先使用本地查找(经常是通过/etc/hosts的内容)。对于本教程,我们将一般地假设有一个IP地址可用,不过下面讨论编写名称查找代码。
(3)主机名称解析
命令行实用程序nslookup可以被用来根据符号名称查找主机IP地址。实际上,许多常见的实用程序,比如ping或者网络配置工具,也会顺便做同样的事情。但是以编程方式做这样的事情很简单。
在Python或者其他非常高级的脚本语言中,编写一个查找主机IP地址的实用程序是微不足道的事情:
- #!/usr/bin/env python
- "USAGE: nslookup.py <inet_address>"
- import socket, sys
- print socket.gethostbyname(sys.argv[1])
- $ ./nslookup.py gnosis.cx
- 64.41.64.172
- /* Bare nslookup utility (w/ minimal error checking) */
- #include <stdio.h> /* stderr, stdout */
- #include <netdb.h> /* hostent struct, gethostbyname() */
- #include <arpa/inet.h> /* inet_ntoa() to format IP address */
- #include <netinet/in.h> /* in_addr structure */
- int main(int argc, char **argv) {
- struct hostent *host; /* host information */
- struct in_addr h_addr; /* Internet address */
- if (argc != 2) {
- fprintf(stderr, "USAGE: nslookup <inet_address>\n");
- exit(1);
- }http://write.blog.csdn.net/postedit/8945754
- if ((host = gethostbyname(argv[1])) == NULL) {
- fprintf(stderr, "(mini) nslookup failed on '%s'\n", argv[1]);
- exit(1);
- }
- h_addr.s_addr = *((unsigned long *) host->h_addr_list[0]);
- fprintf(stdout, "%s\n", inet_ntoa(h_addr));
- exit(0);
- }
(3) 创建套接字(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()时系统会自动随机分配一个端口。
(4)绑定地址和端口(bind)
函数:int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
正如上面所说bind()函数为创建好的socket绑定一个地址族。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。函数的三个参数分别为:
* sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
* addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,如ipv4对应的是:
- struct sockaddr_in {
- sa_family_t sin_family; /* address family: AF_INET */
- in_port_t sin_port; /* port in network byte order */
- struct in_addr sin_addr; /* internet address */
- };
- /* Internet address. */
- struct in_addr {
- uint32_t s_addr; /* address in network byte order */
- };
- struct sockaddr_in6 {
- sa_family_t sin6_family; /* AF_INET6 */
- in_port_t sin6_port; /* port number */
- uint32_t sin6_flowinfo; /* IPv6 flow information */
- struct in6_addr sin6_addr; /* IPv6 address */
- uint32_t sin6_scope_id; /* Scope ID (new in 2.4) */
- };
- struct in6_addr {
- unsigned char s6_addr[16]; /* IPv6 address */
- };
- #define UNIX_PATH_MAX 108
- struct sockaddr_un {
- sa_family_t sun_family; /* AF_UNIX */
- char sun_path[UNIX_PATH_MAX]; /* pathname */
- };
通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。
(5)主机字节序与网络字节序转换(htonl)
主机字节序就是我们平常说的大端和小端模式。不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。采用大小模式对数据进行存放的主要区别在于在存放的字节顺序,大端方式是低地址存放高位字节(内存起始处存放大端),小端方式是低地址存放低位字节(内存起始处存放小端)。采用大端方式进行数据存放符合人类的正常思维,而采用小端方式进行数据存放利于计算机处理。x86平台使用小端模式的主机字节序。
网络上的数据流是字节流,对于一个多字节数值,在进行网络传输的时候,先传递哪个字节?也就是说,当接收端收到第一个字节的时候,它是将这个字节作为高位还是低位来处理呢?网络字节序是收到的第一个字节被当作高位看待,这就要求发送端发送的第一个字节应当是高位。而在发送端发送数据时,发送的第一个字节是该数字在内存中起始地址对应的字节。可见多字节数值在发送前,在内存中数值应该以大端法存放。网络字节序说的是大端字节序。比如我们经过网络发送0x12345678这个整型,在80x86平台中,它是以小端法存放的,因此在发送前需要使用系统提供的htonl将其转换成大端法存放。
所以在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序,而不要假定主机字节序跟网络字节序一样使用的是Big-Endian。由于这个问题曾引发过血案!公司项目代码中由于存在这个问题,导致了很多莫名其妙的问题,所以请谨记对主机字节序不要做任何假定,务必将其转化为网络字节序再绑定到socket。
下面是几个字节顺序转换函数:
htonl():把32位值从主机字节序转换成网络字节序
htons():把16位值从主机字节序转换成网络字节序
ntohl():把32位值从网络字节序转换成主机字节序
ntohs():把16位值从网络字节序转换成主机字节序
(6)服务端侦听(listen)和客户端连接(connect)
函数:
int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。
(7)接收请求(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描述字就被关闭。
(8)数据传输(send/recv)
万事具备只欠东风,至此服务器与客户已经建立好连接了。可以调用网络I/O进行读写操作了,即实现网络中不同进程之间的通信!网络I/O操作有下面几组:
* read()/write()
* send()/recv()
* readv()/writev()
* sendmsg()/recvmsg()
* sendto()/recvfrom()
我推荐使用sendmsg()/recvmsg()函数,这两个函数是最通用的I/O函数,实际上可以把上面的其它函数都替换成这两个函数。它们的声明如下:
- #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);
write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数。失败时返回-1,并设置errno变量。 在网络程序中,当我们向套接字文件描述符写时有俩种可能。一是write的返回值大于0,表示写了部分或者是全部的数据。二是返回的值小于0,此时出现了错误。我们要根据错误类型来处理。如果错误为EINTR表示在写的时候出现了中断错误。如果为EPIPE表示网络连接出现了问题(对方已经关闭了连接)。
其它的我就不一一介绍这几对I/O函数了,具体参见man文档或者baidu、Google,下面的例子中将使用到send/recv。
(9)关闭套接字(close)
函数:int close(int fd);
在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。注意close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
您也能够调用shutdown()函数来关闭该socket。该函数允许您只停止在某个方向上的数据传输,而一个方向上的数据传输继续进行。例如您可以关闭某socket的写操作而允许继续在该socket上接受数据,直至读入任何数据。
int shutdown(int sockfd,int how);
sockfd是需要关闭的socket的描述符。参数how允许为shutdown操作选择以下几种方式:
0-------不允许继续接收数据
1-------不允许继续发送数据
2-------不允许继续发送和接收数据
均为允许则调用close()。shutdown在操作成功时返回0,在出现错误时返回-1并置相应errno。
2、Socket中的TCP握手过程
(1)三次握手建立连接
我们知道tcp建立连接要进行“三次握手”,即交换三个分组。大致流程如下:
* 客户端向服务器发送一个SYN J,尝试连接服务器
* 服务器向客户端响应一个SYN K,并对SYN J进行确认ACK J+1,表示服务器可用,可以建立连接了
* 客户端再向服务器发一个确认ACK K+1,连接建立成功
这三次握手发生在socket的那几个函数中呢?请看下图:
图1 建立TCP连接的三次握手过程
从图中可以看出,当客户端调用connect时,触发了连接请求,向服务器发送了SYN J包,这时connect进入阻塞状态;服务器监听到连接请求,即收到SYN J包,调用accept函数接收请求,向客户端发送SYN K ,ACK J+1,这时accept进入阻塞状态;客户端收到服务器的SYN K ,ACK J+1之后,这时connect返回,并对SYN K进行确认;服务器收到ACK K+1时,accept返回,至此三次握手完毕,连接建立。
总结:客户端的connect在三次握手的第二次返回,而服务器端的accept在三次握手的第三次返回。
(2)四次握手释放连接
socket中,通过四次握手关闭TCP连接的过程,请看下图:
图2 关闭TCP连接的四次握手过程
图示过程如下:
* 某个应用进程首先调用close主动关闭连接,这时TCP发送一个FIN M;
* 另一端接收到FIN M之后,执行被动关闭,对这个FIN进行确认。它的接收也作为文件结束符传递给应用进程,因为FIN的接收意味着应用进程在相应的连接上再也接收不到额外数据;
* 一段时间之后,接收到文件结束符的应用进程调用close关闭它的socket。这导致它的TCP也发送一个FIN N;
* 接收到这个FIN的源发送端TCP对它进行确认。
这样每个方向上都有一个FIN和ACK。
建立一个连接需要三次握手,而终止一个连接要经过四次握手,这是由TCP的半关闭(half-close)造成的。由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。这个原则是当一方完成它的数据发送任务后就能发送一个FIN来终止这个方向的连接。收到一个FIN只意味着这一方向上没有数据流动,一个TCP连接在收到一个FIN后仍能发送数据。首先进行关闭的一方将执行主动关闭,而另一方执行被动关闭。下面是一个更详细的图解。
图3 TCP关闭的四次握手过程
过程如下:
1)客户端A发送一个FIN,用来关闭客户A到服务器B的数据传送(报文段4)。
2)服务器B收到这个FIN,它发回一个ACK,确认序号为收到的序号加1(报文段5)。和SYN一样,一个FIN将占用一个序号。
3)服务器B关闭与客户端A的连接,发送一个FIN给客户端A(报文段6)。
4)客户端A发回ACK报文确认,并将确认序号设置为收到序号加1(报文段7)。
对TCP连接建立与关闭的一些疑问解释如下。
1)为什么建立连接协议是三次握手,而关闭连接却是四次握手呢?
这是因为服务端的LISTEN状态下的SOCKET当收到SYN报文的建连请求后,它可以把ACK和SYN(ACK起应答作用,而SYN起同步作用)放在一个报文里来发送。但关闭连接时,当收到对方的FIN报文通知时,它仅仅表示对方没有数据发送给你了;但未必你所有的数据都全部发送给对方了,所以你可以未必会马上会关闭SOCKET,也即你可能还需要发送一些数据给对方之后,再发送FIN报文给对方来表示你同意现在可以关闭连接了,所以它这里的ACK报文和FIN报文多数情况下都是分开发送的。
2)为什么TIME_WAIT状态还需要等2MSL后才能返回到CLOSED状态?
这是因为虽然双方都同意关闭连接了,而且握手的4个报文也都协调和发送完毕,按理可以直接回到CLOSED状态(就好比从SYN_SEND状态到ESTABLISH状态那样);但是因为我们必须要假想网络是不可靠的,你无法保证你最后发送的ACK报文会一定被对方收到,因此对方处于LAST_ACK状态下的SOCKET可能会因为超时未收到ACK报文,而重发FIN报文,所以这个TIME_WAIT状态的作用就是用来重发可能丢失的ACK报文。
3、一个TCP回显服务器例子
下面是一个简单地回显服务器例子,使用TCP。它发送数据和接收回完全相同的数据。事实上,很多机器出于调试目的而运行“回显服务器”。
服务器端server.c:一直监听本机的8000号端口,如果收到连接请求,将接收请求并接收客户端发来的消息,然后向客户端返回消息。
- #include<stdio.h>
- #include<stdlib.h>
- #include<string.h>
- #include<errno.h>
- #include<sys/types.h>
- #include<sys/socket.h>
- #include<netinet/in.h>
- #define DEFAULT_PORT 8000
- #define MAXPENDING 10 /* Max connection requests */
- #define BUFFSIZE 1024
- void Die(char *mess){ perror(mess); exit(1); }
- void HandleClient(int sock){
- char buffer[BUFFSIZE];
- int received=-1;
- /* 接收消息 */
- if((received=recv(sock, buffer, BUFFSIZE, 0))<0){
- Die("Failed to receive initial bytes from client");
- }
- /* 发送字节,并在循环中检查是否有更多进来的数据 */
- while(received>0){
- /* 把接收的数据发送回去 */
- if(send(sock, buffer, received, 0)!=received){
- Die("Failed to send bytes to client");
- }
- /* 检查更多进来的数据 */
- if((received=recv(sock, buffer, BUFFSIZE, 0))<0){
- Die("Failed to receive additional bytes from client");
- }
- }
- close(sock);
- }
- int main(int argc, char** argv)
- {
- int socket_fd, connect_fd;
- struct sockaddr_in servaddr, clientaddr;
- char buff[4096];
- int n;
- //创建TCP Socket
- if( (socket_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1 ){
- Die("Failed to create socket");
- }
- //构造地址族结构
- memset(&servaddr, 0, sizeof(servaddr));
- servaddr.sin_family = AF_INET;
- servaddr.sin_addr.s_addr = htonl(INADDR_ANY); //IP地址设置成INADDR_ANY,让系统自动获取本机的IP地址
- servaddr.sin_port = htons(DEFAULT_PORT); //设置的端口为DEFAULT_PORT
- //将地址族绑定到套接字上
- if( bind(socket_fd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1){
- Die("Failed to bind the server socket");
- }
- //监听是否有客户端连接
- if(listen(socket_fd, MAXPENDING) == -1){
- Die("Failed to listen on server socket");
- }
- printf("======waiting for client's request======\n");
- /* 一直运行,直到取消 */
- while(1){
- //阻塞直到有客户端连接,不然多浪费CPU资源
- unsigned int clientlen=sizeof(clientaddr);
- if((connect_fd = accept(socket_fd, (struct sockaddr*)&clientaddr, &clientlen)) == -1){
- printf("accept socket error: %s(errno: %d)",strerror(errno),errno);
- continue;
- }
- fprintf(stdout, "Client connected: %s\n", inet_ntoa(clientaddr.sin_addr));
- //处理客户连接
- HandleClient(connect_fd);
- }
- }
用于回显连接的处理器程序HandleClient()很简单。它所做的工作就是接收任何可用的初始字节,然后循环发回数据并接收更多的数据。对于短的(特别是小于 BUFFSIZE) 的)回显字符串和典型的连接,while 循环只会执行一次。但是底层的套接字接口 (以及 TCP/IP) 不对字节流将如何在 recv() 调用之间划分做任何保证。 传入处理函数的套接字是已经连接到发出请求的客户机的套接字。一旦完成所有数据的回显,就应该关闭这个套接字。父服务器套接字被保留下来,以便产生新的子套接字,就像刚刚被关闭那个套接字一样。
编写客户机应用程序所涉及的步骤在TCP和UDP之间稍微有些区别。对于二者来说,您首先都要创建一个socket;单对TCP来说,下一步是建立一个到服务器的连接;向该服务器发送一些数据;然后再将这些数据接收回来;或许发送和接收会在短时间内交替;最后,在TCP的情况下,您要关闭连接。
客户端client.c:
- #include<stdio.h>
- #include<stdlib.h>
- #include<string.h>
- #include<errno.h>
- #include<sys/types.h>
- #include<sys/socket.h>
- #include<netinet/in.h>
- #define BUFFSIZE 1024
- void Die(char *mess) { perror(mess); exit(1); }
- int main(int argc, char** argv)
- {
- int sockfd, n,rec_len;
- char recvline[BUFFSIZE], sendline[BUFFSIZE];
- char buf[BUFFSIZE];
- struct sockaddr_in servaddr;
- if( argc != 2){
- printf("usage: ./client <ipaddress>\n");
- exit(1);
- }
- //创建socket
- if( (sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0){
- Die("Failed to create socket");
- }
- //构造服务器地址族
- memset(&servaddr, 0, sizeof(servaddr));
- servaddr.sin_family = AF_INET;
- servaddr.sin_port = htons(8000);
- if(inet_pton(AF_INET, argv[1], &servaddr.sin_addr) <= 0){
- printf("inet_pton error for %s\n",argv[1]);
- exit(1);
- }
- //连接服务器
- if(connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0){
- Die("Failed to connect with server");
- }
- printf("send msg to server: \n");
- fgets(sendline, BUFFSIZE, stdin);
- //发送数据到服务器
- if(send(sockfd, sendline, strlen(sendline), 0) < 0) {
- Die("Send msg error");
- }
- //从服务器接收数据
- if((rec_len = recv(sockfd, buf, BUFFSIZE,0)) == -1) {
- Die("Receive msg error");
- }
- buf[rec_len] = '\0';
- printf("Received: %s",buf);
- //关闭套接字
- close(sockfd);
- exit(0);
- }
编译server.c:gcc -o server server.c
启动进程:./server,等待客户端连接。
编译client.c:gcc -o client server.c
客户端去连接server:./client 127.0.0.1
等待输入消息,输入一条消息,就会回显到客户端。
使用Python来实现
Python提供了两个基本的socket 模块。第一个是Socket,它提供了标准的BSD Sockets API。第二个是SocketServer,它提供了服务器中心类,可以简化网络服务器的开发。Python 使用一种异步的方式来实现这种功能。
在Python标准库文档中有Socket库的详细介绍,有回显服务器的详细实现例子(同时支持IPv4和IPv6)。参考:http://docs.python.org/2/library/socket.html
在Python HOWTO文档,有关于怎么使用Socket的完整介绍,参考:http://docs.python.org/2/howto/sockets.html
4、一个UDP服务器例子
TCP应用程序相比,UDP客户机和服务器彼此更为相似。本质上,其中的每一个都主要由一些混合在一起的sendto()和recvfrom()调用组成。服务器的主要区别不过就是它通常将其主体放在一个无限循环中以保持提供服务。
下面是udpserver.c:
- #include <stdio.h>
- #include <stdlib.h>
- #include <sys/socket.h>
- #include <arpa/inet.h>
- #include <string.h>
- #include <unistd.h>
- #include <netinet/in.h>
- #define BUFFSIZE 4096
- void Die(char *mess) { perror(mess); exit(1); }
- int main(int argc, char *argv[]) {
- int sock;
- struct sockaddr_in echoserver;
- struct sockaddr_in echoclient;
- char buffer[BUFFSIZE];
- unsigned int echolen, clientlen, serverlen;
- int received=0;
- if(argc!=2){
- fprintf(stderr, "USAGE: %s <port>\n", argv[0]);
- exit(1);
- }
- /* 创建UDP socket */
- if((sock=socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP))<0){
- Die("Failed to create socket");
- }
- /* 构造地址族 */
- memset(&echoserver, 0, sizeof(echoserver)); /* Clear struct */
- echoserver.sin_family=AF_INET; /* Internet/IP */
- echoserver.sin_addr.s_addr=htonl(INADDR_ANY); /* Any IP address */
- echoserver.sin_port=htons(atoi(argv[1])); /* server port */
- /* 绑定socket */
- serverlen=sizeof(echoserver);
- if(bind(sock, (struct sockaddr *)&echoserver, serverlen)<0){
- Die("Failed to bind server socket");
- }
- /* 运行直到取消 */
- while(1){
- /* 从客户端接收一条消息 */
- clientlen=sizeof(echoclient);
- if((received=recvfrom(sock, buffer, BUFFSIZE, 0,
- (struct sockaddr *)&echoclient,
- &clientlen))<0){
- Die("Failed to receive message");
- }
- fprintf(stderr, "Client connected: %s\n", inet_ntoa(echoclient.sin_addr));
- /* 向客户端发送消息 */
- if(sendto(sock, buffer, received, 0,
- (struct sockaddr *)&echoclient,
- sizeof(echoclient)) != received){
- Die("Mismatch in number of echo'd bytes");
- }
- }
- }
UDP服务器不需要listen和accept客户的连接请求。服务器socket并不是传输消息所通过的实际socket;相反,它充当一个特殊socket的工厂,这个特殊socket会在recvfrom()调用中配置。在主循环中,我们是在一个recvfrom()调用中永久地等待接收一条客户消息。此时,echoclient结构将使用客户连接过来生成的socket相关成员来填充,这样就接收到客户的消息和相关地址信息,然后我们在后续的sendto()调用中使用该结构,以向该客户端发送消息。
我们可以不断地接收和发送消息,同时在此过程中向控制台报告连接情况。当然,这种安排一次仅做一件事情,这对于处理许多客户机的服务器来说可能是一个问题,对这个简单的echo服务器来说或许不是问题,但是更复杂的情况可能会引入糟糕的延迟。
UDP客户机比TCP客户机简单一点,它不需要建立连接,只需使用sendto()来将消息发送到指定的地址,而不是在已建立的连接上使用send()。 当然,这需要两个额外的参数来指定预期的服务器地址。下面是udpclient.c:
- #include <stdio.h>
- #include <stdlib.h>
- #include <sys/socket.h>
- #include <arpa/inet.h>
- #include <string.h>
- #include <unistd.h>
- #include <netinet/in.h>
- #define BUFFSIZE 4096
- void Die(char *mess) { perror(mess); exit(1); }
- int main(int argc, char *argv[]){
- int sock;
- struct sockaddr_in echoserver;
- struct sockaddr_in echoclient;
- char buffer[BUFFSIZE];
- unsigned int echolen, clientlen;
- int received=0;
- if(argc!=4){
- fprintf(stderr, "USAGE: %s <server_ip> <word> <port>\n", argv[0]);
- exit(1);
- }
- /* 创建UDP socket */
- if((sock=socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP))<0){
- Die("Failed to create socket");
- }
- /* 构造地址族 */
- memset(&echoserver, 0, sizeof(echoserver)); /* Clear struct */
- echoserver.sin_family=AF_INET; /* Internet/IP */
- echoserver.sin_addr.s_addr=inet_addr(argv[1]); /* IP address */
- echoserver.sin_port=htons(atoi(argv[3])); /* server port */
- /* 向服务器发送消息 */
- echolen=strlen(argv[2]);
- if(sendto(sock, argv[2], echolen, 0,
- (struct sockaddr *) &echoserver,
- sizeof(echoserver)) != echolen){
- Die("Mismatch in number of sent bytes");
- }
- /* 从服务器接收消息 */
- fprintf(stdout, "Received: ");
- clientlen=sizeof(echoclient);
- if((received=recvfrom(sock, buffer, BUFFSIZE, 0,
- (struct sockaddr *)&echoclient,
- &clientlen)) != echolen){
- Die("Received a packet from an unexpected server");
- }
- /* 检查接收是否来自正确的服务器 */
- if(echoserver.sin_addr.s_addr!=echoclient.sin_addr.s_addr){
- Die("Received a packet from an unexpected server");
- }
- buffer[received]='\0'; /* Assure null-terminated string */
- fprintf(stdout, buffer);
- fprintf(stdout, "\n");
- /* 关闭套接字 */
- close(sock);
- exit(0);
- }
在接收数据时,结构echoserver已在对sendto()的调用期间使用一个特殊端口来配置好了;相应地,通过对recvfrom()的调用echoclient 结构得到类似的填充。如果其他某个服务器或端口在我们等待接收回显时发送数据包,会导致接收来自其他的服务器,这需要我们比较两个地址。我们至少应该最低限度地谨防我们不感兴趣的无关数据包(为了确保完全肯定,也可以检查 .sin_port 成员)。在这个过程的结尾,我们打印出发回的数据包,并关闭该socket。
运行服务器和客户机:
- $ ./udpserver 8000 &
- [1] 3476
- $ ./udpclient 127.0.0.1 jackmessage 8000
- Client connected: 127.0.0.1
- Received: jackmessage
在运行客户机是,要指定服务器地址,要发送的消息和端口。
使用Python来实现
下面研究用Python来编写UDP回显应用。在能够测试一个客户机UDPecho应用程序之前,我们需要做的第一件事情就是设法让服务器运行起来,以便客户机能够与之通信。事实上,Python为我们提供了高级SocketServer模块,它允许我们只需最少的自定义工作就能编写socket服务器。
(1)用高级SocketServer模块编写UDP回显服务器
- #!/usr/bin/env python
- "USAGE: %s <port>"
- from SocketServer import DatagramRequestHandler, UDPServer
- from sys import argv
- class EchoHandler(DatagramRequestHandler):
- def handle(self):
- print "Client connected:", self.client_address
- message=self.rfile.read()
- self.wfile.write(message)
- if len(argv)!=2:
- # print USAGE information
- print __doc__ %argv[0]
- else:
- # argv[1] provide port number
- UDPServer(('',int(argv[1])), EchoHandler).serve_forever()
(2)用低级socket编写服务器
与使用SocketServer相比,使用socket模块来编写一个Python UDP服务器并不需要任何更多的代码行,但是代码编写风格更有强制性(实际上像C)。
- #!/usr/bin/env python
- "USAGE: %s <server> <word> <port>"
- from socket import *
- from sys import argv
- if len(argv)!=2:
- print __doc__ %argv[0]
- else:
- sock=socket(AF_INET, SOCK_DGRAM)
- # argv[1] is server address
- sock=bind(("", int(argv[1])))
- while 1: # Run until cancelled
- message, client=sock.recvfrom(256) # <=256 byte datagram
- print "Client connected:", client
- sock.sendto(message, client)
(3)UDP回显客户机
编写一个Python客户机一般要首先编写基本的socket模块。幸运的是,通过高级起点编写几乎用于任何用途的客户机是如此容易。不过要注意,诸如Twisted之类的框架包括了用于此类任务的基类,因此几乎就用不着去思考。下面让我们考察一下基于socket 的UD 回显客户机。
- #!/usr/bin/env python
- "USAGE: %s <server> <word> <port>"
- from socket import * # import *, but we'll avoid name conflict
- from sys import argv, exit
- if len(argv)!=4:
- # print USAGE information
- print __doc__ %argv[0]
- exit(1)
- # UDP socket
- sock=socket(AF_INET, SOCK_DGRAM)
- messout=argv[2]
- # sendto(message, (server_address, port_number))
- sock.sendto(messout, (argv[1], int(argv[3])))
- messin, server=sock.recvfrom(4096)
- if messin!=messout:
- print "Failed to receive identical message"
- print "Received:", messin
- sock.close()
运行服务器和客户机很简单。服务器通过一个端口号来启动。客户机接受三个参数: 服务器地址、要回显(echo)的字符串,以及端口。
- $ ./UDPechoserver.py 8000 &
- [1] 3163
- $ ./UDPechoclient.py
- USAGE: ./UDPechoclient.py <server> <word> <port>
- $ ./UDPechoclient.py localhost foobar 8000
- Client connected: ('127.0.0.1', 56682)
- Received: foobar
在Python中,所有socket模块函数处理幕后的名称解析。因此我们可以指定localhost名称。但是对于C客户机,您必须使用点分四组的IP地址。如果想要在C客户机种执行查找,您需要编写一个DNS函数 ―― 比如在本教程第一部分中介绍的那个函数。
5、可扩展的服务器
我们研究过的服务器(除了回显消息外,不做其他任何事情)能够极其快速地处理每个客户机请求。但是对于更一般的情况,我们可能希望服务器执行可能较长的操作,比如数据库查找、访问远程资源,或者执行复杂计算以便确定客户机的响应能力。我们的“一次做一件事情”的模型无法很好地扩展到处理多个客户机。
(1)单进程服务器
为了说明其中的要点,让我们考察一个稍微修改后的Python服务器,这个服务器是单线程的,它需要花一些时间才能完成一个客户请求任务。而且为了强调服务器正在处理请求,我们还在此过程中(无足轻重地)修改消息字符串:
- #!/usr/bin/env python
- from socket import *
- from sys import argv
- def lengthy_action(sock, message, client_addr):
- from time import sleep
- print "Client connected:", client_addr
- sleep(5)
- sock.sendto(message.upper(), client_addr)
- sock = socket(AF_INET, SOCK_DGRAM)
- sock.bind(('',int(argv[1])))
- while 1: # Run until cancelled
- message, client_addr = sock.recvfrom(256)
- lengthy_action(sock, message, client_addr)
- #!/usr/bin/env python
- from socket import *
- import sys, time
- from thread import start_new_thread, get_ident
- start = time.time()
- threads = {}
- sock = socket(AF_INET, SOCK_DGRAM)
- def request(n):
- sock.sendto("%s [%d]" % (sys.argv[2],n),
- (sys.argv[1], int(sys.argv[3])))
- messin, server = sock.recvfrom(255)
- print "Received:", messin
- # 运行完后从threads list里删除当前线程
- del threads[get_ident()]
- # 创建20个线程来不断运行request
- for n in range(20):
- id = start_new_thread(request, (n,))
- threads[id] = None
- #print id,
- while threads: time.sleep(.1)
- sock.close()
- print "%.2f seconds" % (time.time()-start)
- $ ./UDPechoclient2.py localhost "Hello world" 7
- Received: HELLO WORLD [7]
- Received: HELLO WORLD [0]
- ...
- Received: HELLO WORLD [18]
- Received: HELLO WORLD [2]
- 103.96 seconds
(2)线程化的服务器
我们设置“长操作”服务器的方式保证了它至少要花五秒钟的时间来给任何给定的请求提供服务。但是没有理由说多个线程不能在那同样的五秒钟内运行。同样,受CPU约束的进程明显不会通过线程化而运行的更快,但是在实际的服务器中,那五秒可能主要花在诸如针对另一台机器执行数据库查询等事情上。换句话说,我们应该能够并行地给多个客户机线程提供服务。
一种明显的方法就是使服务器线程化,就像使客户机线程化一样:
- #!/usr/bin/env python
- from socket import *
- from sys import argv
- from thread import start_new_thread
- def lengthy_action(sock, message, client_addr):
- from time import sleep
- print "Client connected:", client_addr
- sleep(5)
- sock.sendto(message.upper(), client_addr)
- sock = socket(AF_INET, SOCK_DGRAM)
- sock.bind(('',int(argv[1])))
- while 1: # Run until cancelled
- message, client_addr = sock.recvfrom(256)
- start_new_thread(lengthy_action, (sock, message, client_addr))
在我的测试系统(与以前一样使用 localhost)上,这样将客户机运行时间减少到了大约9秒 ―― 其中5秒花在调用sleep() 上,其余的4秒花在线程化和连接开销上(大致如此)。
(3)分支服务器
在类UNIX系统上,分支甚至比线程化更容易。进程通常要比线程“重”,但是在诸如Linux、FreeBSD和Darwin这样的流行Posix系统上,进程创建仍然是相当高效的。 使用Python,我们“长操作”服务器版本可以像下面这样简单:
- #!/usr/bin/env python
- from socket import *
- from sys import argv, exit
- from os import fork
- def lengthy_action(sock, message, client_addr):
- from time import sleep
- print "Client connected:", client_addr
- sleep(5)
- sock.sendto(message.upper(), client_addr)
- exit()
- sock = socket(AF_INET, SOCK_DGRAM)
- sock.bind(('',int(argv[1])))
- while 1: # Run until cancelled
- message, client_addr = sock.recvfrom(256)
- if fork():
- lengthy_action(sock, message, client_addr)
(4)异步服务器
另一种称为异步或非阻塞socket 的技术甚至可能比线程化或分支方法更有效率。异步编程背后的概念是将执行保持在单个线程内,但是要轮询每个打开的socket,以确定它是否有更多的数据在等待读入或写出。然而非阻塞socket实际上仅对受I/O约束的进程有用。我们使用sleep()创建的受CPU约束的服务器模拟就在一定程度上遗漏了这个要点,它只使用了阻塞式的socket。此外,非阻塞socket对TCP连接比对UDP连接更有意义,因为前者保持一个可能仍然具有未决数据的打开连接。
先理清阻塞与非阻塞的概念。阻塞函数在完成其指定的任务以前不允许程序调用另一个函数。例如程序执行一个读数据的函数调用时,在此函数完成读操作以前将不会执行下一程序语句。当服务器运行到accept语句时,而没有客户连接服务请求到来,服务器就会停止在accept语句上等待连接服务请求的到来。这种情况称为阻塞(blocking)。而非阻塞操作则可以立即完成。比如如果你希望服务器仅仅注意检查是否有客户在等待连接,有就接受连接,否则就继续做其他事情,则可以通过将Socket设置为非阻塞方式来实现。非阻塞socket在没有客户在等待时就使accept调用立即返回。如下设置:
- ...
- sockfd = socket(AF_INET,SOCK_STREAM,0);
- fcntl(sockfd,F_SETFL,O_NONBLOCK);
- ...
int select(int numfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其中readfds、writefds、exceptfds分别是被select()监控的读、写和异常处理的文档描述符集合。假如您希望确定是否能够从标准输入和某个socket描述符读取数据,您只需要将标准输入的文档描述符0和相应的sockdtfd加入到readfds集合中;numfds的值是需要检查的号码最高的文档描述符加1,这个例子中numfds的值应为sockfd+1;当select返回时,readfds将被修改,指示某个文档描述符已准备被读取,您能够通过FD_ISSSET()来测试。为了实现fd_set中对应的文档描述符的配置、复位和测试,他提供了一组宏:
FD_ZERO(fd_set *set)----清除一个文档描述符集;
FD_SET(int fd,fd_set *set)----将一个文档描述符加入文档描述符集中;
FD_CLR(int fd,fd_set *set)----将一个文档描述符从文档描述符集中清除;
FD_ISSET(int fd,fd_set *set)----试判断是否文档描述符被置位。
timeout参数是个指向struct timeval类型的指针,他能够使select()在等待timeout长时间后没有文档描述符准备好即返回。struct timeval数据结构为:
- struct timeval {
- int tv_sec; /* seconds */
- int tv_usec; /* microseconds */
- };
为了展示异步服务器具有更高的效率,我们先模拟低带宽连接,创建一个具有慢速socket连接的TCP客户机,然后通过使用select()构建一个异步的TCP服务器。用异步服务器来处理慢速的客户连接,以说明异步服务器是如何高效地处理慢速连接。
我们可以创建这样一个客户端,它在发送数据时引入人为的延时,并且逐字节地发出消息。为了模拟许多这样的连接,我们可以创建多个连接线程(每个都是慢速的)。一般来说,这个客户机与我们在上面看到的UDPechoclient2.py类似,只不过是TCP版本:
- #!/usr/bin/env python
- from socket import *
- import sys, time
- from thread import start_new_thread, get_ident
- threads = {}
- start = time.time()
- def request(n, mess):
- # 创建TCP socket
- sock = socket(AF_INET, SOCK_STREAM)
- # argv[1] is server address, argv[3] is port number
- sock.connect((sys.argv[1], int(sys.argv[3])))
- messlen, received = len(mess), 0
- # 逐字节地发送消息,并引入人为延时
- for c in mess:
- sock.send(c)
- time.sleep(.1)
- # 逐字节地接收消息,并引入人为延时
- data = ""
- while received < messlen:
- data += sock.recv(1)
- time.sleep(.1)
- received += 1
- sock.close()
- print "Received:", data
- del threads[get_ident()]
- # 创建20个线程来不断执行慢速的request
- for n in range(20):
- message = "%s [%d]" % (sys.argv[2], n)
- id = start_new_thread(request, (n, message))
- threads[id] = None
- while threads:
- time.sleep(.2)
- print "%.2f seconds" % (time.time()-start)
- #!/usr/bin/env python
- from socket import *
- import sys
- def handleClient(sock):
- data = sock.recv(32)
- while data:
- sock.sendall(data)
- data = sock.recv(32)
- newsock.close()
- if __name__=='__main__':
- sock = socket(AF_INET, SOCK_STREAM)
- sock.bind(('',int(sys.argv[1])))
- sock.listen(20)
- while 1: # Run until cancelled
- newsock, client_addr = sock.accept()
- print "Client connected:", client_addr
- handleClient(newsock)
- $ ./echoclient2.py localhost "Hello world" 7
- Received: Hello world [0]
- Received: Hello world [1]
- Received: Hello world [5]
- ...
- Received: Hello world [16]
- 37.07 seconds
下面是异步服务器,使用select()来多路复用socket。看它如何避免我们刚才客户机引入的那种延时(或者由于确实很慢的连接而频繁产生的那种延时)。
- #!/usr/bin/env python
- from socket import *
- import sys, time
- from select import select
- if __name__=='__main__':
- while 1:
- sock = socket(AF_INET, SOCK_STREAM)
- sock.bind(('',int(sys.argv[1])))
- print "Ready..."
- data = {}
- sock.listen(20)
- # 等待20个客户连接
- for _ in range(20):
- newsock, client_addr = sock.accept()
- print "Client connected:", client_addr
- data[newsock] = ""
- # 上一次有活动socket的时刻
- last_activity = time.time()
- while 1:
- # 多路复用方式监控20个socket连接,TCP连接是双工的,可读可写
- # 因此可读socket集合与可写socket集合相同
- read, write, err = select(data.keys(), data.keys(), [])
- # 若超过5秒还没有活动的socket,则彻底关闭所有连接
- if time.time() - last_activity > 5:
- for s in read: s.shutdown(2)
- break
- for s in read:
- data[s] = s.recv(32)
- for s in write:
- if data[s]:
- last_activity = time.time()
- s.send(data[s])
- data[s] = ""
这个服务器比较简单,因为它是在等待准确的20个客户机连接之后再用select()去监控。但是它仍然说明了使用密集的轮训循环,以及仅当数据在特定的socket上可用时才读/写数据的的基本概念。select()的返回值分别是可读、可写和错误的socket列表的三元组。这其中的每一种类型都是根据需要在循环中处理的。
顺便说一句,使用这种异步服务器允许“慢速连接”客户机在大约6秒的时间内完成全部20个连接,而不是需要37秒(至少在我的测试系统上是这样)。
在介绍上面的服务器时,我坚持使用了Python中相对低级的功能。 像asyncore或SocketServer这样一些高级模块 ―― 或者甚至 threading而不是thread ―― 都可能提供更“具有Python性质”的技术。然而,我使用的这些低级功能在结构上仍然相当接近您在C中要编写的相同内容。Python的动态类型化和简洁的语法仍然节省了一些代码行,但是C程序员应该能够使用我的例子作为类似的C服务器的基本框架。
6、结束语
本教程中介绍的服务器和客户机很简单,但是它们展示了用C和Python编写Socket应用程序所必需的每一方面。更高级的客户机或服务器本质上不过是来回地传输更有趣的数据;socket层的代码和本文中的这些例子并没有什么不同。
执行线程化、分支和异步socket处理的一般要点可类似地应用于更高级的服务器。您的服务器和客户机本身可能会做更多的事情,但是针对可扩展性的策略始终是这三种方法之一(或者它们的组合)。
参考文献:
Linux Socket编程(不限Linux):http://www.cnblogs.com/skynet/archive/2010/12/12/1903949.html
Linux socket 编程,第一部分:http://www.ibm.com/developerworks/cn/education/linux/l-sock/
Linux socket 编程,第二部分:http://www.ibm.com/developerworks/cn/education/linux/l-sock2/