有时候我们在进行网络编程的时候,不得不和socket打交道,以前一遇到socket就很畏惧,这次下定决心来研究一下 这一块内容,选什么语言呢?java不行,它封装的太好,于了解底层的细节没有帮助,还是去看C语言的实现才能看得更 透彻。之前跟项目组长进行交流,他说学好了c语言和UNIX环境编程,底层的一些概念和理解就不成问题了,事后想了 想这真是这么回事儿,现在就来聊一聊C语言的socket编程。
Socket套接字
说到socket套接字,可能觉得很泛,为了具有普适性,我尽量从TCP和UDP来说说套接字的底层实现。
套接字的结构如何?
int sock = socket(AF_INEF,SOCK_STREAM,IPPROTO_TCP);
上面的代码创建了一个socket套接字,AF_INEF表示建立的是IPV4套接字,后面两个参数表示使用的是基于TCP的 流的协议。最关键的是这个返回值sock,这可不是普通的返回码,而是系统创建的指向socket数据结构的描述符, 它是整数型的,与文件描述符的概念相同。那么套接字结构里的内容是什么呢?
1. 与套接字关联的本地和远程Internet地址以及端口号。
2. 一个等待递送所接受数据的FIFO队列("RecvQ")和一个等待传输的数据的FIFO队列("SendQ")。
3. 对于TCP连接,有相关的打开和关闭TCP握手的协议状态信息,初始值为Closed。
对于UDP的套接字,比TCP的要简单,就是没有连接的状态信息,我们最常使用的netstat命令就是对这个底层
数据结构的快照。进程对套接字的处理都是通过sock这个“句柄”来完成的,由于进程对于文件描述符的个数是有
限制的,所以一个进程能建立的socket连接也是有限的。
socket的地址结构是怎样的?
struct sockaddr_in { sa_family_t sin_family; //网络协议(AF_INEF) in_port_t sin_port; //端口(16位无符号整数) struct in_addr sin_addr; //IPV4地址(32位,如果是ipv6:128位) char sin_zero[8]; //字符填充 }
后面的注释已经对socket的地址信息解释的很清楚了,对于tcp连接,server端需要将监听的socket与服务器的 地址进行绑定,这样socket才能正常监听客户端的连接,这个动作就是绑定:
if(bind(servsock,(struct sockaddr*)&servAddr, sizeof(servAddr) < 0){ puts("bind() failed"); }
对于TCP我们在bind完之后需要进行Listen监听和阻塞等待accept,两个函数如下:
int listen(int socket,int queueLimit) int accept(int socket,struct sockaddr *clientAddress,socklen_t *addressLength)
但是对于UDP这两部显然是多余的,只要给UDPbind上地址,它就可以开始接受UDP包了。
TCP和UDP在接收和发送消息时有何区别?
对于TCP: ssize_t numBytes = send(sock,echoString,echoStringLen,0); numBytes = recv(sock,buffer,BUFSIZE-1,0);
对于UDP: ssize_t sendto(int,sock,const void msg,size_t msgLength,int flags,const structsockaddr destAddr,socketlen_t addrlen);
ssize_t recvfrom(int,sock,const void *msg,size_t msgLength,int flags,const structsockaddr *destAddr,socketlen_t addrlen);
从上面的代码中就可以看出,面向连接的TCP不需要在send中指定目的地,因为TCP的socket本身就是成对存在的。
在socket中怎么使用名称(DNS)?
我们在编码的时候并不总是知道服务器的具体IP地址,我们可能只知道域名,而且IP地址难以记忆且可能经常 改变,所以在socket中使用DNS是必须要有的。
int getaddrinfo(const char *hostStr,const char *serviceStr,const sruct addrinfo *hints,struct addrinfo **results) int getnameinfo(const struct sockaddr *address,socklen_t addressLength,char *node,socklen_t nodeLength,char *service,socklen_t serviceLength,int flags)
第一个函数就是解析域名的,返回的结果是指向addrinfo结构的链表的指针。为什么要返回一个链表结果呢?因为 对于主机和服务的每种组合,可能有地址族(V4或者V6)和套接字类型/协议(流/TCP或数据包/UDP)的多种不同的 组合表示可能的断点;再者主机名可能对应多个IP地址。当然我们也不用去考虑太复杂,因为第三个参数的地址 格式会为我们过滤地址格式,这个函数的功能实在强大。
第二个函数就是一个“逆函数”,它通过地址信息返回解析的对应域名。
流与Socket
首先说明流只能用于TCP套接字。
FILE *fdopen(int socketdes, const char *mode);
size_t fwrite(const void *ptr,size_t size,size_t nmemb,FILE *stream);
size_t fread(void *ptr,size_t size,size_t nmemb,FILE *stream);
从第一个函数可以看出socket的句柄居然可以使用流打开,这也应征了一个结论就是套接字的句柄跟文件描述符
没有本质上的区别。通过这种方式,我们就可以轻松地将结构写入流中,也可以从流中读出一个数据结构,而且
fread永远不会从流中读取对象的一部分,每次都是读取给定大小的给定数量的对象,fwrite也是一样。这样相当
与将一个结构覆盖在一片内存区域上,这就需要字节的对齐和填充(一般是自动完成)。这样是不是方便很多。
信号与socket
因为应用程序接收到大部分信号采取的默认行为都是终止进程,所以如果编写服务端的进程不进行信号的处理,
很容易因为意外的错误而终止服务进程,这是我们不愿意看到的,所以为了服务进程的健壮性,我们不得不
对信号进行处理。
所以在TCP套接字上发送数据的任何程序都必须显式地处理SIGPIPE信号。
为什么呢?考虑这样的情况:在TCP中,如果客户端突然的关闭连接,而服务器可能不知道这个信息,当服务
端去发送数据的时候,这个时候服务器就会知晓这个连接已经断开,就会递送一个SIGPIPE信号,导致服务进程
的退出,所以处理SIGPIPE是必要的,一般的做法是将SIGPIPE设置成SIG_IGN,这样就可以忽略该信号。
我们都知道kill命令是专门向进程发送信号的,进程是不能忽略SIGKILL信号(9号)的,所以我们总能够通过
kill -9 ${PID}来杀死进程。
何为异步IO?
在编写阻塞IO程序的时候,我们是为每一个客户端编写一个进程或者线程来处理,这样的开销可想而知,服务器 不可能承受太多的连接数,这样我们想到了非阻塞的IO,用一个进程或线程去轮询IO事件,这样是不是会浪费 很多的CPU时间,我们能不能让操作系统通知进程套接字的IO事件呢?答案是肯定的。
异步IO的工作方式是:当套接字上发生某个与IO相关的事件时,把SIGIO信号递送给进程,然后进程再去处理 IO事件,大名鼎鼎的select/Epoll模型就是使用的异步非阻塞的IO模型,通常epoll的处理效率会更高,因为 select在接收到信号后还是得去轮询IO描述符,但是epoll会告诉进程确切的信息。
如何实现超时处理?
最典型的例子就是:UDP消息可能丢失,而客户不能辨别是否发生了丢失,当然客户端也不可能永远等待,这 就需要一个超时机制,譬如当2秒钟内还没有收到回应,就认为服务不可用,或者重新尝试一下。怎么实现呢?
标准方法是在阻塞函数之前设置一个alarm:
unsigned int alarm(unsigned int secs);
当计时器到期的时候,就把SIGALRM信号发送给进程,并且执行用于SIGALRM的处理函数,如果接收到相应的消息, 就将计时器取消。
通过信号,我们还可以收回僵尸进程,当子进程终止时,它不会自动消失,而是变成了僵尸进程,会消耗系统的 资源,这样我们可以通过SIGCHLD信号来收回僵尸进程(waitpid),因为子进程终止的时候会发送SIGCHLD信号给 父进程。
通过信号可以做的事情真是太美妙了。