下面是一个《UNIX网络编程 卷1:套接字联网API(第3版)》书中的一个例子,但是没有使用书中代码给出的unp.h头文件,直接包含的所需要的头文件,也没有使用包装函数,该程序用于客户端获取服务器端的当前时间。
服务器端代码:
#define MAXLINE 4096
#define PORT 6563
#define LISTENQ 1024
#include<stdio.h>
#include<string.h>
#include<time.h>
#include<netinet/in.h>
#include<unistd.h>
int main(int argc, int *argv[]) {
int listenfd, connfd;
struct sockaddr_in servaddr;
char buff[MAXLINE];
time_t ticks;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(PORT);
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
listen(listenfd, LISTENQ);
while(1) {
connfd = accept(listenfd, (struct sockaddr *)NULL, NULL);
ticks = time(NULL);
snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
write(connfd, buff, strlen(buff));
close(connfd);
}
}
客户端代码:
#define MAXLINE 4096
#define PORT 6563
#include<stdio.h>
#include<stdlib.h>
#include<netinet/in.h>
#include<string.h>
#include<sys/socket.h>
#include<unistd.h>
#include<arpa/inet.h>
int main(int argc, char **argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
struct sockaddr_in servaddr;
if(argc < 2) {
printf("parameter error");
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(PORT);
if(inet_pton(AF_INET, argv[1], &servaddr.sin_addr) <= 0) {
printf("server address error");
exit(-1);
}
connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
while((n = read(sockfd, recvline, MAXLINE)) > 0) {
recvline[n] = '\0';
if(fputs(recvline, stdout) == EOF) {
printf("fputs error");
}
}
if(n < 0) {
printf("read error\n");
}
exit(0);
}
上面的两段代码都没有进行错误处理,对于连接错误,socket函数执行错误都是没有提示的。这是一段最基本的套接字程序,在程序中客户端和服务器端使用TCP协议通信,服务器端启动后监听客户端的连接请求,客户端连接到服务器端后,服务器端获取当前的时间然后向客户端发送,程序代码很简单,但是涉及到了套接字网络编程的最基本的几个步骤,所有的套接字网络编程都离不开这几个步骤。下面根据上面的代码来学习基本的TCP套接字编程中的核心函数和常量。
socket函数
为了进行网络I/O,一个进程必须做的第一件事就是调用socket函数,指定期望的通信协议类型,如指定使用IPv4的TCP、使用IPv6的UDP、Unix域字节协议等,函数返回一个小的非负整数,这个值称为套接字描述符,与文件描述符类似,通过这个描述符可以引用套接字。socket函数声明如下:
#include<sys/socket.h>
int socket(int family, int type, protocol);
socket函数在头文件sys/socket.h,但是在程序中包含netinet/in.h文件后,可以不包含sys/socket.h文件,因为包含netinet/in.h后,sys/socket.h文件里边的定义的宏、结构体和函数就变得可见了,在 netinet/in.h文件的介绍中有这么一句
Inclusion of the <netinet/in.h> header may also make visible all symbols from <inttypes.h> and <sys/socket.h>.
至于为什么,还没弄清楚。
其中family参数指明协议类型,它是一个常值,可取值为:
type参数指明套接字类型
protocol参数映射值为IPPROTO_TCP、IPPROTO_UDP、IPPROTO_SCTP,分别代表TCP传输协议,UDP传输协议和SCTP传输协议,或者将该值指定为0,由系统默认给定的family和type组合。
bind函数
bind函数把一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是32位的IPv4地址或128位IPv6地址与16位TCP或UDP端口号的组合。bind函数的定义为:
#include<sys/socket.h>
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);//成功则返回0,出错则返回-1
bind函数的第一个参数是套接字描述符,第二个参数是特定与协议的地址结构的指针,第三个参数是该地址结构的长度。对于TCP,调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以两者都不指定。如果不指定IP地址,那么bind函数就使用通配地址,由内核将等套接字已连接(TCP)或已在套接字上发出数据包(UDP)时才选择一个本地地址,如果不指定端口号,那么端口号参数就为0,内核就在bind调用时选择一个临时端口。上面的程序中使用INADDR_ANY来指定通配地址,对于IPv6则使用in6addr_any变量,in6addr_any变量在头文件<netinet/in.h>中已经初始化为IN6ADDR_ANY_INIT常值。
如果由内核选择一个临时的端口号,那么可由函数getsockname()来返回协议地址,而不是由bind的函数中的参数来返回,因为bind函数中第二个参数是const类型。
listen函数
listen函数由TCP服务器调用,它做两件事情:
- 当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客户端套接字。listen函数把一个未连接的套接字转换为一个被动套接字,指示内核应该接收指向该套接字的连接请求。调用listen函数将导致套接字从CLOSED状态转换到LISTEN状态
- 该函数的参数backlog规定了内核应该为相应套接字排队的最大连接数
listen函数的定义为:
#include<sys/socket.h>
int listen(int sockfd, int backlog);//若执行成功,则返回0,出错则返回-1
这个函数在socket函数和bind函数之后调用,并在调用accept函数之前调用。第二个参数backlog规定了内核为相应套接字排队的最大连接数。在内核中,为每个监听套接字维护两个队列:
- 未完成连接队列,即客户端发送SYN分解后,服务器还未与其完成三次握手过程,这样的的套接字处于SYN_RCVD状态,并未建立真正的连接;
- 已完成连接队列,服务器与客户端完成三次握手过程之后的套接字所处的队列,这些套接字处于ESTABLISHED状态。
然而backlog的含义并未有过正式的定义,有的操作系统将backlog定义为未完成连接队列与已完成连接队列总和的最大值,有的操作系统定义为未处理连接构成的队列可能增长到的最大长度(但是其中未处理连接并未定义清楚),所以根据具体情况而定,一般在应用程序中通过参数指定这个值。
accept函数
accept函数由TCP服务器调用,用于从已完成连接队列对头返回下一个已完成连接,如果已完成连接为空,那么进程被投入睡眠(假定在阻塞方式下),函数定义为:
#include<sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);//若成功则为非负描述符,若出错则为-1
参数cliaddr用来返回客户端进进程协议地址,addrlen用于指定客户端协议地址长度,返回时,该整数即为由内核放在该套接字地址结构内的确切字节数,如果不需要得到客户端的地址,可以给这两个参数传递空指针,如上面程序中给定的空指针。
accept函数执行成功返回一个非负整数,它是当前客户端与服务器连接的套接字描述符描述符,称为已连接套接字描述符,这个值由内核自动生成。socket函数同样返回一个套接字描述符,称为监听套接字描述符。这两个描述符必须区分开来。
connect函数
如果是TCP套接字,调用connect函数将激发TCP的三路握手过程,而且仅在建立成功或出错时才返回,函数定义为:
#include<sys/socket.h>
int connect(int sockfd, const struct *servaddr, socklen_t addrlen);//若成功则返回0,若出错返回-1
其中出错返回可能有一下几种情况:
- 连接超时,客户端发TCP的第一个SYN分节之后,没有收到服务器端的响应分节;
- 服务器端主机在connect函数指定的端口上没有进程监听(例如服务进程没有运行),此时客户端收到的SYN响应是RST(表示复位);
- 远程服务器不可达错误,客户端在发送SYN之后,在中间某个路由器上引发一个“destination unreachable”的ICMP错误。
close函数
close函数用于关闭打开的套接字,并终止TCP连接,函数定义为:
#include<unistd.h>
int close(int sockfd);//若成功则返回0,出错返回-1
使用close函数关闭一个套接字描述符后,这个套接自描述符就不能再使用了。
TCP连接建立过程与关闭
了解了上述函数之后,下面就根据上述函数和程序来学习TCP建立连接的三次握手过程和关闭连接的四次握手过程:
当服务器端调用socket函数根据给定的的参数创建一个套接字描述符sockfd, 再调用bind函数将一个本地协议地址赋予套接字描述符sockfd,需要注意的是bind函数给定的本地地址类型必须符合套接字描述符sockfd中规定的协议参数,例如套接字描述符sockfd中指定的AF_INET,那么bind函数绑定的是IPv4的地址,而不能是IPv6的地址,然后调用listen函数将fd表示的套接字转换成被动的套接字,从而等待客户端的连接请求,再调用accept函数之后,服务器就处于监听客户端请求的状态了,在服务器端内核给每个监听的套接字维护有两个队列即未完成连接队列和已完成连接队列,当进程调用accept函数时,就从已完成连接队列中取出对头返回给进程,如果该队列为空,则进程将进入睡眠状态。
那么已完成队列中的元素是如何产生的呢?当客户端调用connect函数后,就激发了TCP三路握手过程,向服务器端发送了一个SYN分组,服务器端内核监听套接字接收到SYN分组后,就在未完成连接对列中创建一个新项K,此时服务器端当前的这个客户端对应的套接字就处于SYN_RCVD状态,然后服务器端向客户端发送一个服务器端的SYN响应,其中捎带对客户SYN的ACK,客户端收到这个服务器端的ACK(即TCP三次握手的第二个分节)时就从connect函数返回,然后向服务器端发送对服务器的SYN分节的响应,未完成连接队列中的这一项K直到客户端发送对服务器端SYN的响应ACK之前一直在未完成连接队列中(超时暂不考虑),如果服务器端接收到了客户端对服务器端SYN的响应ACK,那么就将未完成队列中的这一项K就从未完成连接队列中移至已完成连接队列的队尾,此时服务器端的这个客户端对应的套接字就处于ESTABLISHED状态,这样,已完成队列中就有元素了,调用accept函数时就可以从已完成队列中取出队头元素。TCP连接建立的三路握手过程完全是自动的,无需服务器进程插手。
close函数被调用后,会导致发送一个FIN,随后就是正常的TCP连接终止序列,自动完成TCP连接关闭的四路握手过程,也无需进程插手。
用图片表示如下:
图片来源:http://learn.akae.cn/media/ch37s02.html
并发与close函数
在上面的程序中服务器端只有一个进程与客户端进行通信,服务器端对客户端提供的服务是串行的,即服务完一个客户,再服务下一个客户,有没有办法同时服务多个客户呢?可以使用多进程或者多线程的方法来解决。多进程方式就是在accept函数成功之后,利用函数fork()函数创建一个新的进程来处理新建立的连接。如下面的一段伪代码:
pid_t pid;
int listenfd, connfd;
listenfd = socket(...);
bind(listenfd...);
while(1) {
connfd = accept(listenfd,...);
if((pid = fork()) == 0) {
close(listenfd);
doit(connfd);
close(connfd);
exit(0);
}
close(connfd);
}
在上面的代码中每当一个新的连接到达,就创建一个新的进程来处理,由于在Unix中,创建进程后,子进程将会拥有和父进程同样的代码和资源,所以必须进行区分,fork()函数返回两次,即在父进程中返回一次,在子进程(新创建的进程)中返回一次,在父进程中返回值是新创建的子进程的非0的ID值,子进程中返回的是0,这样就可以在代码中区分父进程与子进程。上面的代码中,如果是子进程就使用close函数关闭监听套接字描述符,然后根据已连接套接字描述符对连接进行处理,如果是父进程,则关闭刚刚创建的已连接套接字描述符,继续监听。父进程与子进程拥有同样的资源和代码,所以也就拥有同样的套接字描述符,所以在父进程和子进程中的监听套接字描述符listenfd代表的都是同样的监听套接字,同样两个已连接套接字描述符connfd所代表的也是同一个套接字,如果父进程关闭套接字,子进程是中的套接字是否也关闭了呢?是否向客户端发送了FIN分节呢?答案是否定的。因为每个套接字描述符都有一个引用计数,它表示当前打开者的引用该套接字的描述符的个数,fork()一个新进程之后,connfd和listenfd的引用计数就变为2,那么执行close函数时只是将引用计数减1,套接字真正的清理和资源释放要等到其引用计数值达到0时才发生。所以在并发的多线程中这样使用close是合理的。
Reference
《UNIX网络编程 卷1:套接字联网API(第3版)》