我们不妨先来看下tcp客户端/服务端程序的套接字函数
我们可以看到服务端的起始到结束包含了 socket()->bind()->listen()->read()<->write()->close()
而客户端则是socket()->connect()->write()<->read()->close()
接下来,我们顺着这个调用顺序,从socket开始讲解tcp套接字函数
socket函数在
int socket(int family, int type, int protocol);
期中family是协议族,就是指示是使用IPv4还是IPv6或者一些更特殊的协议族,常用的主要是AF_INET,AF_INET6,分别表示IPv4和IPv6
type则是套接字类型,包括字节流套接字,数据包套接字等,常用的主要是SOCK_STREAM(常用于TCP),SOCK_DGRAM(UDP)
protocol是指特定的协议,因为family和protocol一般组合起来已经可以确定协议,所以可以填0表示使用默认值,不过也可以特别用来指定使用STCP等
int tcp_fd=socket(AF_INET,SOCK_STREAM,0);//应用于IPv4协议采用tcp
int udp_fd=socket(AF_INET,SOCK_DGRAM,0);//应用于IPv4协议采用udp
下面是family具体可取值以及解释
family | 意义 |
---|---|
AF_INET | IPv4协议 |
AF_INET6 | IPv6协议 |
AF_LOCAL | unix域协议 |
AF_ROUTE | 路由套接字 |
AF_KEY | 秘钥套接字 |
下面则是type具体可取值以及解释
type | 意义 |
---|---|
SOCK_STREAM | 字节流套接字,常用于TCP |
SOCK_DGRAM | 数据包套接字,常用于UDP |
SOCK_SEQPACKET | 有序分组套接字,常用于SCTP |
SOCK_RAW | 原始套接字,可以认为是IP层 |
我们之前提到两两组合的可能性,下面是特定的family和type组合后的结果,空格表示不可组合
AF_INET | AF_INET6 | AF_LOCAL | AF_ROUTE | AF_KEY | |
---|---|---|---|---|---|
SOCK_STREAM | TCP/SCTP | TCP/SCTP | 可用 | ||
SOCK_DGRAM | UDP | UDP | 可用 | ||
SOCK_SEQPACKET | SCTP | SCTP | 可用 | ||
SOCK_RAW | IPv4 | IPv6 | 可用 | 可用 |
下面是protocol的具体取值,可以看到就是tcp,udp和sctp
protocol | 说明 |
---|---|
IPPROTO_CP | TCP传输协议 |
IPPROTO_UDP | UDP传输协议 |
IPPROTO_SCTP | STCP传输协议 |
其实除了AF_之外,还有PF_,因为最早的时候,人们曾经想过一个协议族(PF)可以支持多个地址族(AF),然后PF用于创建套接字,AF用于创建地址,但实际时从来没有使用过,PF一般默认就是跟AF同值,不然可能会崩溃
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen) ;
期中sockfd是从socket函数返回的文件描述符,servaddr则是目标服务器的地址,addrlen则是服务器长度
当然,你可以在connect之前调用bind去绑定一个ip地址,但是其实对于客户端来说,调用connect的时候系统就会识别IP地址,并且自动指定一个端口给你了
if ((status=connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr))<0){}//connect返回如果小于0,代表着发生了错误
connect会触发三次握手,然后只有在成功或者出错的时候返回,出错的原因可能有:
之前提到如果收到RST报文就xxx,那么什么情况下会收到RST报文呢?
会因为什么情况收到ICMP报文呢?
虽然我们说会返回ENETUNREACH,但是其实网络不可达的信息一般认为已过时,我们一般只会返回EHOSTUNREACH
看起来名字很冗长,其实就是E-HOST-UNREACH 以及E-NET-UNREACH
当我们使用socket函数之后,套接字处于CLOSED状态,调用connec的话,会进入SYN_SENT状态,如果成功的话,套接字会进入ESTABLISHED状态,但是如果失败了,那么这个套接字就不能再用connect了,必须close掉然后重新connect
具体来说,如果ASN到达服务器,只是没有对应的进程,就会返回RST报文(第二种情况),如果在过程中发现不可达,那么就返回ICMP报文,如果什么事情都没有发生,只是单纯的(可能因为丢包什么的)而没有消息,就返回timeout
首先我们要祭出一个用来访问13端口(获取时间)的client,这是第二章里面的内容,然后,我们可以开始模仿上述条件
当访问得不到响应的时候,触发Timeout(等待的时间会比较长,约75s)
一个简单的方法,是请求连接一个子网上没有的节点比方说(192.168.1.100),然后arp试图找到相应硬件失败就会触发了
RST报文
空缺
ICMP
空缺
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);//成功返回0,否则返回-1
bind会试图讲一个本地协议地址与套接字绑定,本地协议地址是 32位的ipv4地址/128位的ipv6地址+16位的UDP/TCP端口号
struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr)); //初始化servaddr
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); //表示通配IP地址
servaddr.sin_port = htons(13); //表示绑定13端口(这是周知端口,用来汇报时间)
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));//绑定
IP地址的选择可以是通配的(如上述htonl(INADDR_ANY)
),在这种情形下,对于客户端来说,内核会根据所用的外出网络接口来确定使用的IP地址,如果是服务器,内核则会将SYN报文中指定的目的IP地址作为服务器的源IP地址(注意这里面说的概念,你可以认为一个主机可以有多个IP地址,比方说服务器能收到SYN报文,说明服务器的确有这么个IP)
当然也可以选择绑定主机有的网络接口的IP地址,在这种情形下,对于客户端来说,就相当于指定了自己的IP地址(一般来说客户端不需要绑定),对于服务端来说,就限定了这个套接字接受连接的范围
port也同样是可以通配的(通过指定0来实现),事实上,无论是客户端还是服务器,如果在调用connect和listen之前没有调用bind函数,内核都会随机指定一个端口,这对于客户端来说是可以接受的,但是服务端一般不可以,因为服务端依赖于周知端口来被人所认识(不然客户端不知道该用什么样的端口来访问服务端)
一个例外是rpc服务器
如果让内核指定随机端口,bind的返回值并不指示端口号码,我们要用getsockname()来返回协议地址
如果端口随机,那么会在调用bind的时候分配一个随机端口,但是如果IP地址随机,那么会在第一次成功建立连接(TCP)或者在套接字上成功发出数据报的时候才确定(UDP)
int listen(int sockfd, int backlog);
listen函数主要完成两件事情:
Listen(listenfd, LISTENQ);// 这里面LISTENQ是作者指定的1024,因为本着如果不支持这么大的backlog,那么内核会裁切的原则,如果检索会发现,很多实现设置为128
要理解backlog的含义,我们需要理解,如果我们回顾三次握手,我们会注意到服务器接收到SYN报文然后发送ACK并且进入SYN_RCVD状态,与最后收到ACK进入ESTANBLISHED状态,是有时间差的,因此当前的实现,是用两个队列,一个存储的是进入SYN_RCVD的套接字,我们称之为未完成连接队列,一个存储的是进入ESTANBLISHED状态的套接字,我们称之为已完成连接队列
当服务器收到一个连接请求的时候,就会将该连接加入未完成连接队列,等待如果得到ACK之后,会将该项转移到已完成连接队列
那么,backlog代表的是什么呢?
backlog是已完成连接队列的最大长度,当然有那么一段时间表示的是两个队列总和长度,而且当前的实现都提供了一个“模糊因子“,这是因为需要为未完成连接队列提供额外的长度
当队列已满的时候,新的SYN报文会被丢弃(但是不会发送RST报文),这是因为逻辑上,允许客户端过一段时间重发看是否还有没有满
当三次握手连接完成之后,服务器调用accept之前的数据,会被存储在已连接套接字的接收区缓冲区
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
注意的是,我们这里有两个套接字概念,第一个是accept中的第一个参数,同时来自于socket->listen,称为监听套接字,第二个套接字来自于accept返回的套接字,称之为连接套接字(这是针对客户的)
cliaddr和addrlen是用于返回给调用者client信息的,如果不需要可以设置为NULL,注意addrlen是典型的值-结果参数
struct sockaddr_in cliaddr;
socklen_t len;
connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &len);
printf("connection from %s,port %d \n", inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),ntohs(cliaddr.sin_port));
pid_t fork(void);
fork会创建一个新的子进程,一个比较迷惑人的地方在于,fork有两个返回值,对于父进程来说,fork的返回值是创建的子进程的pid,而对于子进程来说,返回的是0,因此一般我们可以用返回值来区分执行逻辑
pid_t pid;
if ((pid=fork())<0)
{
//error
}else if (pid==0){
//子进程逻辑
print("Hello world");
exit(0);//注意这句话只会停止执行子进程,因为只有子进程会进入这个条件分支,用exit的办法,那么父进程就不需要嵌套在else里面(来避免子进程执行)
}
//父进程逻辑
fork创建子进程的话一般可能有两种使用用途:
fork之前打开的所有描述符都会被子进程共享,所以一般来说我们可以fork一个子进程,然后子进程继续处理已连接套接字,而父进程关闭了该已连接套接字
for (;;)
{
// 返回的是已连接描述符,用于与连接的客户通信
connfd = Accept(listenfd, (SA *)&cliaddr, &len);
if (fork() == 0) //if 里面只有子进程会运行
{
Close(connfd);//因为监听套接字在子进程是不必要的
printf("connection from %s,port %d \n", inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
ntohs(cliaddr.sin_port));
ticks = time(NULL);
snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
Write(connfd, buff, strlen(buff));
Close(connfd);
exit(0);
}
Close(connfd);//因为父进程不操心连接套接字
}
==0的做法在之前已经提到,这样我们可以区分”子进程运行逻辑“和”父进程运行逻辑“
不过可能很多人会好奇,我们之前提到过,如果我们对一个套接字调用close会导致系统发送FIN报文,然后进入正常关闭逻辑,但是我们却在父进程显示close了连接套接字,这难道不会导致套接字关闭吗?
为了明白这点,我们需要理解,每个文件(I/O)和套接字,都会维护一个引用计数,他是当前打开着的引用该文件或者套接字的描述符的个数
一般来说,exit会关闭所有打开的文件描述符,不过可能会有人倾向于显式关闭
int close(int fd);
当只有fd引用文件或者套接字的时候,close指定会标记该套接字为关闭并立即返回(不阻塞),描述符将不再能够用于read/write函数的第一个参数,对于tcp来说,会试图继续发送所有已经排队准备发送的数据,然后发生正常的关闭序列(就是fin报文什么的)
由于close不一定能够关闭描述符(子进程持有),所以也可以调用shutdown来替换close
close函数在服务器中尤其重要,因为首先,如果父进程不关闭,那么很可能会出现描述符耗尽的可能性,其次是如果父进程不关闭,那么连接套接字将永远不被关闭
int getsockname(int sockfd, struct sockaddr * localaddr, socklen_t * addrlen);
int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t * addrlen);
期中getsockname返回的是本机在该套接字上的地址信息,而getpeername返回的是对端在该套接字上的地址信息
其实调用起来很简单,不过我们需要注意的是,如果是服务端,那么应该传入的sockfd应该是连接套接字而不是监听套接字