UNIX网络编程笔记(3)—基本TCP套接字编程

时间:2022-09-26 10:59:27

基本TCP套接字编程

主要介绍一个完整的TCP客户/服务器程序需要的基本套接字函数。

1.概述

在整个TCP客户/服务程序中,用到的函数就那么几个,其整体框图如下:

UNIX网络编程笔记(3)—基本TCP套接字编程


2.socket函数

为了执行网络I/O,一个进程必须要做的事情就是调用socket函数。其函数声明如下:

#include <sys/socket.h>
int socket(int family ,int type, int protocol);

其中:

family:指定协议族
type:指定套接字类型
protocol:指定某个协议,设为0,以选择所给定family和type组合的系统默认值。

这些参数有一些特定的常值定义如下:

faimly 说明
AF_INET IPv4协议
AF_INET6 IPv6协议
AF_LOCAL Unix域协议
AF_ROUTE 路由套接字
AF_KEY 密钥套接字

表1 socket函数的family常值


type 说明
SOCK_STREAM 字节流套接字
SOCK_DGRAM 数据报套接字
SOCK_SEQPACKET 有序分组套接字
SOCK_RAW 原始套接字

表2 socket函数的type常值


protocol 说明
IPPROTO_TCP TCP传输协议
IPPROTO_UDP UDP传输协议
IPPROTO_SCTP SCTP传输协议

表3 socket函数AF_INET或AF_INET6的protocol常值


socket函数调用成功的时候将返回一个小的非负整数值,成为套接字描述符,简称sockfd。为了得到这个描述符,我们制定了协议族和套接字类型,并未指定本地与远程协议地址。

另外,书中还提到一个AF_XXX(表示地址族)和PF_XXX(表示协议族)的区别,一般情况下都使用AF,知道这个就可以了。


3.connect函数

函数声明如下:

#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *servaddr,socklen_t addrlen);

sockfd:由socket返回的套接字描述符。
servaddr:套接字地址结构,包含了服务器IP和端口号。
addrlen:套接字地址结构大小,防止读越界。

客户端调用connect时,将向服务器主动发起三路握手连接,直到连接建立和连接出错时才会返回,这里出错返回的可能有一下几种情况:

1)TCP客户没有收到SYN分节的响应。(内核发一个SYN若无响应则等待6s再发一个,若仍无响应则等待24s后再发送一个。总共等待75s仍未收到则返回错误ETIMEDOUT
2)若对客户的SYN的响应是RST,表明服务器主机在我们指定的端口上没有进程在等待与之连接,客户端收到RST就会返回ECONNREFUSED错误
产生RST的三个条件是:目的地SYN到达却没有监听的服务器;TCP想取消一个已有连接;TCP接收到一个根本不存在连接上的分节。
3)若客户发出的SYN在中间的某个路由器上引发了一个“destination unreachable”(目的地不可达)ICMP错误,则认为是一种软错误,在某个规定时间(比如上述75s)没有收到回应,内核则会把保存的信息作为EHOSTUNREACH或ENETUNREACH错误返回给进程。

若connect失败则该套接字不再可用,必须关闭,我们不能对这样的套接字再次调用connect函数,当循环调用函数connect为给定主机尝试各个ip地址直到有一个成功时,在每次connect失败后,都必须close当前的套接字描述符并从新调用socket。


4.bind函数

bind函数把一个本地协议地址赋予了一个套接字。协议地址时32位IPv4地址或128位的IPv6地址与16位的TCP/UDP端口号的组合。

在调用bind函数可以制定一个特定的端口号,或者制定一个IP地址,或者两个都指定,后者两者都不指定。
函数声明如下:

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr * myaddr,socklen_t addrlen);

参数说明:

sockfd:套接字描述符
myaddr:套接字地址结构的指针
addrlen:上述结构的长度,防止内核越界

服务器在启动时捆绑它们的众所周知的端口,例如时间获取服务的端口13。如果不调用bind函数,当调用connect或listen的时候,TCP会创建一个临时的端口,这对于客户端来说很常见(毕竟我们从来没见过客户端程序调用过bind函数),而对于TCP服务器来说就比较少见了,因为TCP服务器就是通过其众所周知的端口被大家认识。

进程可以把一个特定的IP地址绑定到它的套接字上:对于客户端来说,这没有必要,因为内核将根据所外出网络接口来选择源IP地址。对于服务器来说,这将限定服务器只接收目的地为该IP地址的客户连接。

对于IPv4来说,通配地址由常值INADDR_ANY来指定,其值一般为0,它告知内核去选择IP地址,因此我们经常看到如下语句:

struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

同理,端口号指定为0时,内核就在bind被调用的时候选择一个临时端口,不过bind函数不返回内核选择的值,第二个参数有const限定。如果想要直到内核所选择的临时端口值,必须调用getsockname来返回协议地址。

最后需要注意的是:bind绑定保留端口号时需要超级用户权限。这就是为什么我们在linux下执行服务器程序的时候要加sudo,如果没有超级用户权限,绑定将会失败。


5.listen函数

listen函数由TCP服务器调用,其函数声明如下:

#include <sys/socket.h>
int listen (int sockdfd , int backlog);

listen函数主要有两个作用:

1.对于参数sockfd来说:当socket函数创建一个套接字时,它被假设为一个主动套接字。listen函数把该套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。
2.对于参数backlog:规定了内核应该为相应套接字排队的最大连接数=未完成连接队列+已完成连接队列
其中:
未完成连接队列:表示服务器接收到客户端的SYN但还未建立三路握手连接的套接字(SYN_RCVD状态)
已完成连接队列:表示已完成三路握手连接过程的套接字(ESTABLISHED状态)

结合三路握手的过程:
1.客户调用connect发送SYN分节
2.服务器收到SYN分节在未完成队列建立条目
3.直到三鹿握手的第三个分节(客户对服务器SYN的ACK)到达,此时该项目从未完成队列移动到已完成队列的队尾。
4.当进程调用accept时,已完成队列出队,当已完成队列为空时,accept函数阻塞,进程睡眠,直到已完成队列入队。

所以说,如果三路握手正常完成,未完成连接队列中的任何一项在其中存留的时间就是服务器在收到客户端的SYN和收到客户端的ACK这段时间(RTT)。
如图所示:

UNIX网络编程笔记(3)—基本TCP套接字编程

对于一个WEB服务器来说,RTT是187ms。

关于这两个队列还有需要注意的地方:当客户端发送SYN分节到达服务器时,如果此时服务器的未完成连接队列是满的,服务器将忽略这个SYN分节,服务器不会立即给客户端回应一个RST,因为客户端有自己的重传机制,如果服务器发送RST,那么客户度端的connect就会返回错误了。另外客户端无法区别RST究竟意味着“该端口没有服务器在监听”还是意味着“该端口有服务器在监听不过它的队列满了。”


6.accept函数

TCP服务器调用accept函数,函数声明如下:

#include<sys/socket.h>
int accept (int sockfd, struct sockaddr *cliaddr ,socklen_t * addrlen);

参数说明:

sockfd:套接字描述符
cliaddr:对端(客户)的协议地址
addr:大小

当accept调用成功,将返回一个新的套接字描述符,例如:

int connfd = Accept(listenfd,(SA*)NULL,NULL);

其中我们listenfd为监听套接字描述符,称connfd为已连接套接字描述符。,区分这两个套接字十分重要,一个服务器进程通常只需要一个监听套接字,但是却能够有很多已连接套接字(比如通过fork创建子进程),也就是说每有一个客户端建立连接时就会创建一个connectfd,当连接结束时,相应的已连接套接字就会被关闭。

通过指针我们可以得到客户端的套接字信息,但是如果我们对这些不感兴趣就可以另他们为NULL,书中给出一个示例,服务器相应连接后,打印客户端的IP地址和端口号。
部分代码如下:

#include "unp.h"
#include <time.h>
int
main(int argc, char **argv)
{
//...
for ( ; ; ) {
len = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &len);//已连接套接字
//cliaddr获取客户端协议地址信息。
printf("connection from %s, port %d\n",
Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
ntohs(cliaddr.sin_port));

//...
Close(connfd);
}
}

7.fork和exec函数

用fork创建子进程的方法并不陌生,这里将用fork编写并发服务器程序。

#include <unisted.h>
pid_t fork(void);

在子进程中返回0,在父进程返回返回子进程ID,因为有这个父子的概念,所以fork函数调用一次却要返回两次。

关于fork函数的一些特性:

1.任何子进程只能有一个父进程,并且子进程可以通过getppid获取父进程ID
2.父进程中调用fork之前打开的描述符,在fork之后与子进程分享,在网络通信中也正是应用到这个特性。

说到上述第2个特性,我们知道服务器进程往往在死循环中等待客户端连接,利用特性2,当accept函数调用返回一个connfd时,子进程可以利用其进行读写,而父进程直接关闭即可。

简而言之:父进程创建描述符,子进程对其实际操作。

exec函数实际上是6个函数,他们的区别主要在于:
(a)待执行的程序文件是由文件名还是路径名指定。
(b)新程序的参数是一一列出来还是由一个指针数组来引用
(c)把调用进程的环境传递给新程序还是给新程序指定新的环境。
关于EXEC的详情可参考: linux下c语言编程exec函数使用


8.并发服务器

首先一个概念叫做“迭代服务器”,例如:

for(;;)
{
connfd = Accept(listenfd,(SA*)NULL,NULL);
ticks=time(NULL);
snprintf(buff,sizeof(buff),"%.24s\r\n",ctime(&ticks));
Write(connfd,buff,strlen(buff));
Close(connfd);
}

当一个客户端连接过来时,服务器向客户端写入时间信息后,关闭已连接套接字,回到for循环顶部阻塞等待连接的到来,这样每次连接到来的时候,必须完成该次服务,因为它占用了服务器进程。但是由于简单的获取时间服务本身就很快,单次服务马上就完成了,所以也就影响不大,不过如果是十分耗时的服务就不一定了,我们并不希望服务器被单个客户长时间占用,而是希望服务器同时服务多个用户,于是在Unix中编写并发服务器程序最简单的办法就是fork一个子进程来服务每个客户。

pid_t pid;
int listenfd;
int connfd;
listenfd=Socket(/*...*/);
Bind(listenfd,/*...*/);
Listen(listenfd,/*...*/);
for(;;)
{
connfd = Accept(listenfd,(SA*)NULL,NULL);
if((pid=fork())==0)//子进程
{
close(listenfd);//关闭监听套接字
doit(connfd);//服务
close(connfd);
exit(0);
}
close(connfd);
}

这里我一直不理解的是,为什么在子进程里面要关闭监听套接字(listenfd)呢?
这就跟fork的相关知识有关:

1.首先fork并不是把父进程从头到尾执行一遍,否则这样不就无穷尽了。
2.父进程在调用fork处,整个父进程空间会原模原样地复制到子进程中,包括指令,变量值,程序调用栈,环境变量,缓冲区,等等。
3.在并发服务器的示例中,子进程会将已连接的套接字(connfd)和监听套接字(listenfd)拷贝到自己的进程空间。
4.对于套接字描述符来说,他们都有一个引用计数,fork之后由于描述符被复制,其引用计数都变成了2。
5.因此,我们在父进程中关闭connfd(因为我们在子进程中使用connfd),在子进程中关闭listenfd(因为我们在父进程中进行监听),此时他们的引用计数都变成了1。
6.然后,我们所期望的状态就是父进程中保留一个监听套接字继续等待客户端连接,在子进程中通过已连接的套接字对客户端请求进行服务。
7.最后在子进程中关闭connfd,或exit(0),使得connfd真正完成清理和资源释放。


9.close函数

close函数可以用来关闭套接字并终止TCP连接。

#include <unistd.h>
int close(int sockfd);

从上节的并发服务器可以看到,close函数是对套接字描述符的引用计数减1,也就是说,如果调用close后,引用计数不为0,将不会引起TCP的四分组连接终止序列,这正是父进程与子进程共享已连接套接字的并发服务器所期望的。不过如果我们确实想在TCP连接上发送一个FIN,那么调用shutdown函数。


10.getsockname和getpeername函数

#include <sys/socket.h>
int getsockname(int sockfd,struct sockaddr*localaddr,socklen_t *addrlen);
int getpeername(int sockfd,struct sockaddr*peeraddr,socklen_t *addrlen);
//若成功则为0,失败则为-1

这两个函数的作用:
1.首先我们知道在TCP客户端一般不使用bind函数,当connect返回后,getsockname可以返回客户端本地IP地址和本地端口号。
2.如果bind绑定了端口号0(内核选择),由于bind的参数是const型的,因此必须通过getsockname去得到内核赋予的本地端口号。
3.获取某个套接字的地址族
4.以通配IP地址bind的服务器上,accept成功返回之后,getsockname可以用于返回内核赋予该连接的本地IP地址。其中套接字描述符参数必须是已连接的套接字描述符。


11.总结

本章介绍了套接字编程的函数,客户端和服务器端都从socket开始,客户端随后调用connect,而服务器端先后调用bindlistenaccept,最后使用close来关闭描述符。