3_基本TCP套接字编程
函数总结
基本TCP客户服务器程序用到的套接字函数
下图展示一个基本的迭代TCP服务器调用函数过程。
socket函数
执行网络IO时,进程的第一件事就是调用socket函数,指定期望的通信协议类型。
(a)函数原型:
#include<sys/socket.h>
int socket(int family, int type, int protocol);
(b)参数:
int family:指明协议族类型,协议族类型共有:
int type:指明套接字类型
int protocol:指明传输协议类型。可以设为0,表示指定family和type的默认形式。
family和type的有效组合:
(c)返回值
scoket调用成功:则返回一个非负整数sockfd,该整数是一个套接字描述符。类似于文件描述符。套接字一旦建立完成,就保持CLOSED状态,直到后续函数被调用。
出错返回-1.
connect函数
(a)函数原型
#include<sys/socket.h>
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen )
(b)参数
int sockfd:调用socket函数后返回的套接字描述符,它指向一个套接字。
const struct sockaddr *servaddr:指向某个套接字地址结构的指针。
socklen_t addrlen:该套接字地址结构的大小。
(c)调用结果:
客户机调用connect只需要指定目标服务器的套接字地址结构就可以了,用户主机会自动绑定本地IP地址,并分配一个临时端口。
如果是TCP套接字,调用connect函数后,会激发TCP的三次握手,从而建立连接。仅在连接建立成功或出错时才会返回结果,在返回结果前,conncet处于阻塞等待状态。
(d)返回值:
成功时,返回0;
出错时,返回-1。errno共有三种出错类型。
(e)错误处理
根据不同的出错原因,errno会返回不同的错误类型。
在调用connect函数后,TCP会导致当前套接字从CLOSED状态转移到SYN_SENT状态。若成功再转移到ESTABLISHED状态。因此,如果connect调用失败,则该套接字不可再用,必须调用close函数进行关闭。
bind函数(服务器、客户端)
bind函数把一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是本地的一个32位的IPv4地址或128位的IPv6地址,加上16位的TCP或UDP端口号的组合,即本地套接字地址结构。
无论用于服务器还是客户端,bind函数其实都是给某个套接字指定源IP地址和端口。但是用于服务器和客户端时的作用不同。
(a)函数原型
#include<sys/socket.h>
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen)
(b)参数
const struct sockaddr *myaddr:本地协议族地址构成的套接字地址结构(指针)
对于TCP而言,调用bind函数时,可以指定一个端口号,或者一个IP地址,也可以两者都指定,甚至可以都不指定。
无论是客户机还是服务器,都可能时多宿的,即多个IP地址都可以找到它。
(c)客户机捆绑IP地址
如果调用bind绑定IP地址,则为自己发送的IP数据报指派了源IP地址。那么服务器收到客户请求后,会将ACK回复到指定的源IP地址。
一般客户机不掉用bind,直接调用connect。客户机会根据所用外出网络接口自动选择源IP地址,这个选择取决于到达目的IP地址所需路径。
(d)服务器捆绑IP地址:
如果调用bind绑定IP地址,则限定了该套接字只接受那些目的地为这个IP地址的客户连接。例如服务器有两个IP地址111.111.111.111和222.222.222.222,如果某套接字调用bind绑定了111.111.111.111,那么该套接字只处理那些目的IP地址为111.111.111.111的客户请求。
对于多宿服务器,如果不bind绑定IP地址(通配IP地址),那么内核就把客户发送的SYN的目的IP地址作为服务器的源IP地址。也就是内核将等到套接字已连接时才选择一个本地IP地址。
通配情况举例说明:
某主机为多个组织提供Web服务器。每个组织有自己的域名,每个域名映射到一个或多个IP地址。通常这些IP地址在一个子网上。比如子网为192.69.10,那么第一个组织可能为192.69.10.128,第二个组织为192.69.10.129。最后把这些IP地址都定义为单个网络接口的别名。IP层接收所有目的地为任何一个别名地址的外来数据报。例如某客户机请求连接192.69.10.128,IP数据报被这个网络接口捕获,那么主机根据客户请求的IP地址(192.69.10.128),创建一个子进程,该子进程有一个已连接套接字(对),该套接字(对)的主机IP地址变为客户的目的IP地址(192.69.10.128)。
(e)多种可能情况总结
其中通配地址即不指定IP地址,端口为0则不指定端口号(0是保留端口),使用临时端口。如果让内核选择临时端口号,那么bind后我们无法查看端口号。如果想要查看端口号,只能通过调用getsockname函数。
对于IPv4:
通配地址是一个定义的常数INADDR_ANY;
对于IPv6:
通配地址是一个定义的结构:
在头文件<netinet/in.h>中定义的in6addr_any。
赋值实例
IPv4:
struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr=htonl(INADDR_ANY); //转换网络字节序,通配IP
***IPv6:***
struct sockaddr_in6 serv;
serv.sin6_addr=in6addr_any;
(f)返回值
成功返回0
错误返回-1。errno可能返回错误类型EADDRINUSE(地址已被使用)
(g)bind总结
服务器和客户端都是可以调用bind函数的,都是用来确定自己的源IP地址。但是服务器会在调用bind后调用listen函数,来使这个套接字变为监听套接字,因此它们调用bind的目的和产生的效果也不同。
对于客户端来说,调用bind的目的是指定自己发出的数据报源自于哪个IP地址和端口(用来告诉服务器)。
对于服务器来说,调用bind的目的是,指定自己只监听那些目的为这个IP地址和端口的客户请求,那些虽然也能接收到但是目的IP地址不匹配的客户请求,不予处理。
listen函数(服务器)
在TCP调用socket创建一个套接字后,它默认是一个主动套接字,即默认自己的客户端,准备调用connect发起连接。此时,如果调用listen函数,就会将这个主动套接字变为被动套接字(监听套接字),指示内核应该接受指向该套接字的连接请求。
(a)函数原型
#include<sys/socket.h>
int listen(int sockfd, int backlog);
(b)参数
int sockfd:某个服务器的监听套接字套接字描述符,这个套接字需要经过bind。
int backlog:用来规定内核应该为相应的套接字排队的最大连接数(两个队列一共能排多少项)。
(c)返回值
若成功返回0;
失败返回-1.
(d)监听套接字
服务器使用socket创建的,并且给该套接字调用bind和listen函数,这个套接字是监听套接字。他与已连接套接字有区别。
一个服务器一般只创建一个监听套接字。他在服务器的生命周期内一直存在。当使用bind和listen结束后,监听套接字就建立完成了。根据bind绑定的IP地址和端口,监听相应的客户套接字。
(e)监听套接字的队列
内核为任何一个给定的监听套接字维护两个队列。每个队列中都有一些套接字。这些套接字每个都对应一个请求与服务器连接的客户。
a、未完成连接队列
该队列中每个套接字对应一个客户,该客户的请求已经发出并到达服务器,服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RCVD状态。这些套接字处于未完成连接队列。当一个客户SYN到达时,如果队列已满,TCP会忽略该分节,而不是回复RST。这是让客户TCP启动正常的重传机制,。否则客户TCP无法区分到底是队列已满还是该端口服务器不在监听状态。
b、已完成连接队列
每个已经完成TCP三路握手的客户对应该队列中的一个已连接套接字。
(f)队列转换过程
每当监听套接字接收到一个客户请求后,就会在未完成队列中创建一个新的套接字,并将监听套接字的参数全部复制到这个新套接字。新套接字来对客户的SYN进行响应(回复ACK)。一直到三次握手完成,该套接字才会被移动到已完成连接队列的队尾。
(g)backlog的值的确定
backlog的值一般是两个队列的总和。在Berkeley中增设了一个模糊因子,即backlog乘1.5作为队列总和。
backlog的值不能为0,其次,为了满足服务器要求,不能太低。
accept函数
accept函数被TCP服务器调用时,会从已完成连接队列头返回下一个已完成连接。如果此时已完成连接队列为空,那么进程会进入睡眠阻塞。
(a)函数原型:
#include<sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
(b)参数:
int sockfd:监听套接字的套接字描述符。
struct sockaddr *cliaddr:指向服务端创建的一个没有明确值的套接字地址结构。该结构用于接收客户数据,准备将内核中数据写入该结构。
socklen_t *addrlen:值-结果参数。因为这里的cliaddr还没有明确,我们并不知道内核究竟给该套接字地址结构写入了多少字节长度。函数调用前该指针指向的值置为整个套接字地址结构的大小sizeof(*cliaddr),即最大长度;函数调用完变为内核实际写入的字节数。
(c)返回值
accept成功,则返回一个非负的已连接套接字的套接字描述符(int confd);
accept失败,则返回-1。
accpet传入的参数cliaddr和addrlen实际上也是返回值。用于接收函数调用返回的客户套接字地址结构和长度。如果我们对这两个参数不感兴趣,可以直接将这两个参数置为NULL。
fork函数
fork函数用于并发服务器的编程。
(a)函数原型
#include<unistd.h>
pid_t fork(void);
(b)返回值
fork函数非常特殊,父进程调用一次fork函数,就有两次返回。
第一次返回:在调用进程(父进程)中返回一次,返回值是新派生进程(子进程)的进程ID号。 这是因为父进程可能有很多的子进程,而且无法获得各个子进程的ID,因此需要在fork调用时记录返回所创建的子进程ID,用于追踪每个子进程。
第二次返回:在子进程也返回一次,返回值是0。fork函数在子进程返回0而不是父进程ID,是因为每个子进程有且只有一个父进程,子进程只需要调用getppid函数即可取得父进程的进程ID。
返回值用于告知当前进程,自己是父进程还是子进程。
(c)fork函数用法
两个用法:
第一个用法:常用于网络服务器。一个进程调用fork创建一个自身的副本。每个副本都可以独立处理各自的某个操作。
第二个用法:常用于shell之类的程序。用于一个进程想要执行另一个程序。主进程调用fork创建一个副本,然后该副本调用exec函数把自身替换成新的程序。
exec函数
进程调用exec函数,才可以使得存放在硬盘的可执行文件被系统执行。exec函数可以把当前进程映像替换成新的程序文件,然后从该程序的main函数开始执行。不会改变进层ID。调用exec函数的进程称为调用进程,新执行的程序为新程序。
(a)函数原型
#include <unistd.h>
extern char **environ; //外部变量,当前进程环境变量
int execl(const char *path, const char *arg, ...); //list,绝对路径,进程环境变量
int execlp(const char *file, const char *arg, ...); //list,相对路径,进程环境变量
int execle(const char *path, const char *arg , ..., char * const envp[]);
//list,绝对路径,自定义环境变量
int execv(const char *path, char *const argv[]); //vector,绝对路径,进程环境变量
int execvp(const char *file, char *const argv[]); //vector,相对路径,进程环境变量
int execve(const char *path, char *const argv[], char *const envp[]);
//vector,绝对路径,自定义环境变量
(b)六个函数的关系
这六个函数的区别有三个:一是待执行程序文件是由文件名(filename)还是路径名(pathname)指定;二是新程序的参数是一一列出还是由一个指针数组来引用;三是执行环境的区别,是将调用进程的环境传递给新程序还是给新程序指定新的环境。
关系如图所示:
带l的函数表示参数用list形式一个一个给出,每个参数用逗号分割,一个参数是一个字符串。最后用一个空指针结束。
带v的函数表示参数用vector的方式给出,一个字符串数组指针。
带p的表示被调用的待执行程序用相对路径方式给出,直接用filename即可。函数挥从环境变量中去搜索这个程序,并转换为绝对路径path。
带e的表示可以自定义环境变量。
没有e的表示用调用进程的环境变量(environ)执行该程序。
close函数
(a)引用计数
内核中的每一个文件或者套接字都有一个引用计数。该计数维护在文件表项中,用于记录引用该文件或套接字的描述符的个数。一个文件或套接字可以被多个程序或者进程调用,每次调用都会将引用计数加1,每次调用close会将引用计数减1,一直到引用计数为0,意味着该文件没有被调用,才会真正清理和资源释放。父进程调用fork函数后创建子进程,套接字和文件的描述符会被两个进程共享,引用计数也相应加1。
对于迭代服务器而言,调用close直接关闭了listenfd或者connfd。
对于并发服务器而言,调用close只是将引用技术减去1,只要引用仍然大于0,则该套接字不会真正的关闭。
(b)函数原型
#include<unistd.h>
int close(int sockfd);
(c)返回值
成功返回0,出错返回-1.
并发服务器
并发服务器和迭代服务器相对。迭代服务器即一次处理一个客户请求,一段时间内整个服务器被一个客户占用,直到断开连接。并发服务器可以使用fork函数创建子进程,来服务每一个客户。
(a)并发服务器程序轮廓
pid_t pid;
int listenfd,connfd;
listenfd=socket(...);
/*创建并填充套接字地址结构*/
bind(listenfd,...);
listen(listenfd,LISTENQ);
for(;;){
connfd=accept(listenfd, ...);
if((pid=fork())==0){
close(listenfd); //子进程关闭监听套接字
/* 处理已连接套接字connfd */
close(connfd); //子进程关闭已连接套接字
exit(0);
}
close(connfd); //父进程关闭已连接套接字
}
(b)子进程中的套接字引用
创建子进程后,描述符可以在父子进程*享。实际上与客户进行连接的仍然是内核中的同一个套接字。两个进程共享的只是该套接字的描述符。
(c)关于close函数
父进程和子进程都调用了close,但是都没有真正关闭套接字。这是因为子进程的出现增加了套接字的引用计数,无论是监听套接字还是accept返回的已连接套接字。子进程和父进程都调用一次close,才能真正关闭已连接套接字。
getsockname函数
这两个函数用于返回某个套接字的协议地址。
对于每个建立连接的套接字来说,一定是关联两个协议地址的。一个本地协议地址,即源地址;一个是与之通信的外地协议地址,即目标地址。getsockname函数用于返回与某个套接字关联的本地协议地址;getpeername用于返回与某个套接字关联的外地协议地址。
getsockname和getpeername必须在内核中已经有套接字后才可以使用。对于客户端,conncet或者bind后可以使用;对于服务器来说,accetp后才可以使用。
(a)函数原型
#include<sys/socket.h>
int getsockname(int sokcfd, struct sockaddr *localaddr, socklen_t *addrlen);
(b)函数参数
int sockfd:某个套接字的描述符。可以是已连接的,也可以是待连接的。具体见c。
struct sockaddr *localaddr:指向套接字地址结构的指针。这里的指针变量是新创建的NULL指针,用于接收getsockname函数的返回值。
socklen_t addrlen:值-结果参数。因为getsockname是从内核向进程传递参数。对于返回的套接字地址结构,我们并不知道内核究竟给他写入了多少字节。因此要用指针。
(c)返回值
成功返回0,出错返回-1
(d)使用场景
对于TCP客户来说,使用getsockname有两种情形:
connect成功返回后,getsockname用于返回内核赋予该连接的本地IP地址和本地端口号。此时传入的sockfd是一个已连接套接字。
TCP客户调用bind函数绑定源IP地址,但是端口号置为0。此时调用getsockname函数,用于返回内核赋予的本地端口号。此时的套接字可以是未连接套接字。
对于TCP服务器来说:
如果服务器调用bind绑定了IP地址为通配IP地址,则可以在accept返回成功后,使用getsockname函数返回内核赋予该连接的本地IP地址。此时套接字必须是已连接套接字,不能用监听套接字作为参数传入。
getpeername函数
(a)函数原型
#include<sys/socket.h>
int getpeername(int sokcfd, struct sockaddr *peeraddr, socklen_t *addrlen);
(b)参数
int sockfd:已连接套接字描述符
struct sockaddr *peeraddr:对端套接字地址结构。用于接收内核写入的对端套接字地址结构的数据。
socklen_t *addrlen:值-结果参数。
(c)返回值
成功返回0,出错返回-1
(d)使用场景
一般用于服务器获取客户身份。
某个进程调用accept后fork生成子进程来exec执行服务器程序,从而处理客户响应,此时该服务器程序知道客户身份的唯一途径,就是getpeername函数。
如下图所示。
inet调用accept后返回两个值,一个是connfd套接字描述符。一个是客户的套接字地址结构(对端地址)。随后inetd调用fork创建一个子进程。子进程是父进程内存映像的一个副本,父进程的套接字地址结构在子进程中被复制了一份。connfd并没有复制,它只是被父子进程共享。此时子进程调用exec执行真正的服务器程序来处理客户请求,子进程的内存映像被替换成新的服务器程序文件。这就造成子进程的套接字地址结构就此丢失。此时子进程如果想要知道客户的IP地址和端口号,唯一的方法就是调用getpeername函数。套接字描述符connfd不会丢失,可以跨exec使用。
在这个例子中,exec执行服务器程序时,必须在启动之后还能获取connfd的值。否则在服务器程序中,不知道已连接套接字描述符的具体值。获取该值有两个方法。第一个方法是,将这个套接字描述符格式化为一个字符串,作为服务器程序的参数传入;第二个方法是在调用exec前,将已连接套接字描述符保存到一个特定的约定好的描述符,然后就可以在服务器程序中直接使用了。