下面通过最简单的客户端/服务器程序的实例来学习socket API。
echoser.c 程序的功能是从客户端读取字符然后直接回射回去。
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
/*************************************************************************
> File Name: echoser.c > Author: Simba > Mail: dameng34@163.com > Created Time: Fri 01 Mar 2013 06:15:27 PM CST ************************************************************************/ #include<stdio.h> #define ERR_EXIT(m) \ int main(void) struct sockaddr_in servaddr; int on = 1; if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) if (listen(listenfd, SOMAXCONN) < 0) //listen应在socket和bind之后,而在accept之前 struct sockaddr_in peeraddr; //传出参数 char recvbuf[1024]; close(conn); return 0; |
下面介绍程序中用到的socket API,这些函数都在sys/socket.h中。
int socket(int family, int type, int protocol);
socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()调用出错则返回-1。对于IPv4,family参数指定为AF_INET。对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议。如果是UDP协议,则type参数指定为SOCK_DGRAM,表示面向数据报的传输协议。protocol参数的介绍从略,指定为0即可。
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。bind()成功返回0,失败返回-1。
bind()的作用是将参数sockfd和myaddr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听myaddr所描述的地址和端口号。struct sockaddr *是一个通用指针类型,myaddr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。我们的程序中对myaddr参数是这样初始化的:
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(5188);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
首先将整个结构体清零(也可以用bzero函数),然后设置地址类型为AF_INET,网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址,端口号为5188。
int listen(int sockfd, int backlog);
典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未accept的客户端就处于连接等待状态,listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接等待状态,如果接收到更多的连接请求就忽略。listen()成功返回0,失败返回-1。
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
三方握手完成后,服务器调用accept()接受连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。cliaddr是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen参数是一个传入传出参数(value-result argument),传入的是调用者提供的缓冲区cliaddr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给cliaddr参数传NULL,表示不关心客户端的地址。
在上面的程序中我们通过peeraddr打印连接上来的客户端ip和端口号。
在while循环中从accept返回的文件描述符conn读取客户端的请求,然后直接回射回去。
echocli.c 的作用是从标准输入得到一行字符,然后发送给服务器后从服务器接收,再打印在标准输出。
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
/*************************************************************************
> File Name: echoser.c > Author: Simba > Mail: dameng34@163.com > Created Time: Fri 01 Mar 2013 06:15:27 PM CST ************************************************************************/ #include<stdio.h> #define ERR_EXIT(m) \ int main(void)
struct sockaddr_in servaddr; if (connect(sock, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
char sendbuf[1024] = {0}; write(sock, sendbuf, strlen(sendbuf)); fputs(recvbuf, stdout); memset(sendbuf, 0, sizeof(sendbuf)); close(sock);
return 0; |
由于客户端不需要固定的端口号,因此不必调用bind(),客户端的端口号由内核自动分配。注意,客户端不是不允许调用bind(),只是没有必要调用bind()固定一个端口号,服务器也不是必须调用bind(),但如果服务器不调用bind(),内核会自动给服务器分配监听端口,每次启动服务器时端口号都不一样,客户端要连接服务器就会遇到麻烦。
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。connect()成功返回0,出错返回-1。
先编译运行服务器:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echoser
然后在另一个终端里用netstat命令查看:
simba@ubuntu:~$ netstat -anp | grep 5188
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 0.0.0.0:5188 0.0.0.0:* LISTEN 4425/echoser
可以看到server程序监听5188端口,IP地址还没确定下来。现在编译运行客户端:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echocli
回到server所在的终端,看看server的输出:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echoser
recv connect ip=127.0.0.1 port=59431
可见客户端的端口号是自动分配的。
再次netstat 一下
simba@ubuntu:~$ netstat -anp | grep 5188
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 0.0.0.0:5188 0.0.0.0:* LISTEN 4425/echoser
tcp 0 0 127.0.0.1:59431 127.0.0.1:5188 ESTABLISHED 4852/echocli
tcp 0 0 127.0.0.1:5188 127.0.0.1:59431 ESTABLISHED 4425/echoser
应用程序中的一个socket文件描述符对应一个socket pair,也就是源地址:源端口号和目的地址:目的端口号,也对应一个TCP连接。
上面第一行即echoser.c 中的listenfd;第二行即echocli 中的conn; 第三行即echoser.c 中的sock。4425和4852分别是进程id。
现在来做个测试,先把40~42行的代码注释起来。
首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server,结果是:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echoser
bind error: Address already in use
这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。我们用netstat命令查看一下:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ netstat -anp | grep 5188
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 127.0.0.1:5188 127.0.0.1:37381 FIN_WAIT2 -
tcp 1 0 127.0.0.1:37381 127.0.0.1:5188 CLOSE_WAIT 2302/echocli
server终止时,socket描述符会自动关闭并发FIN段给client,client收到FIN后处于CLOSE_WAIT状态,但是client并没有终止,也没有关闭socket描述符,因此不会发FIN给server,因此server的TCP连接处于FIN_WAIT2状态。
现在用Ctrl-C把client也终止掉,再观察现象:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ netstat -anp | grep 5188
(No info could be read for "-p": geteuid()=1000 but you should be root.)
tcp 0 0 127.0.0.1:5188 127.0.0.1:37382 TIME_WAIT -
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echoser
bind error: Address already in use
client终止时自动关闭socket描述符,server的TCP连接收到client发的FIN段后处于TIME_WAIT状态。TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximumsegment lifetime)的时间后才能回到CLOSED状态,需要有MSL 时间的主要原因是在这段时间内如果最后一个ack段没有发送给对方,则可以重新发送。因为我们先Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Linux上一般经过半分钟后就可以再次启动server了。至于为什么要规定TIME_WAIT的时间请大家参考UNP
2.7节。
在server的TCP连接没有完全断开之前不允许重新监听是不合理的,因为,TCP连接没有完全断开指的是connfd(127.0.0.1:8000)没有完全断开,而我们重新监听的是listenfd(0.0.0.0:8000),虽然是占用同一个端口,但IP地址不同,connfd对应的是与某个客户端通讯的一个具体的IP地址,而listenfd对应的是wildcard address。解决这个问题的方法是使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。将原来注释的40~42行代码打开,问题解决。
参考:
《Linux C 编程一站式学习》
《TCP/IP详解 卷一》
《UNP》