限于篇幅,本文将主要讨论在同一计算机下的socket进程间通信
从概念上说,socket和管道没多大区别。但是从实现上来说,管道传递的是无结构的字节流,但是socket传递的是报文。
与V IPC相同,内核为socket设置的总入口为sys_socketcall()
sys_socketcall(int call, unsigned long *args)
之后会像V IPC一样检查所给的宏,然后调用相应的函数。
socket编程分为两种,一种是有连接的(带入TCP),一种是没有连接的(带入UDP)。这两者拥有的数据结构是不一样的,同时两者所调用的函数也是不一样的,为方便计,系统给出了同样的调用端口,下文将会详细说明。
创建套接字socket()
它的底层是sys_socket(),这个函数比较简单,主要是调用了两个函数,一个是create_socket创建套接字,还有一个是sock_map_fd()建立映射。
调用函数create_socket()创建了socket结构体并且分配了数据结构和inode结构,最后映射到文件里。这是属于特殊文件sockfs的文件,初始化的时候会调用函数安装这个文件系统。处理好了socket数据结构还要再创建创建一个sock数据结构。可能会有一些不理解:为什么还要弄一个sock结构?socket数据结构对应着特俗文件系统sockfs的文件,inode的有一个联合体,记录着20多种不同的文件系统。由于是联合体,对于空间来说就有一定限制,sock数据结构就是为了完善socket数据结构的,两者是一一对应的关系。sock结构是内核中经常要动态分配使用的,所以有一个全局的队列,通过slab机制来管理它的缓冲区。所以还要把sock挂到队列里。在创建sock的时候还规定了读写的缓冲区,默认是64k。
接下来调用函数sock_map_fd(),分配一个空闲的file并且确定了打开文件号fid,接下来分配了一个dentry指向inode,file的f_dentry指向dentry。最后根据dentry和inode创建了目录项和索引节点。总而言之,在sockfs根目录下弄了一个文件。函数最后返回打开文件号fid。
在这里插一句pipe和socketpair的区别。为什么一个只能实现读写中的一样而另一个既能读又能写,原因就在这里。pipe不过是在内存里创建了一个文件,由于没有实体,不能够像正常文件一样读写,在读写的时候读写的是mmap()映射到内存的缓冲页面,而是直接对文件读写。但是socketpair确是创建了一个文件,在读写的时候是读写的文件映射到内存上的缓冲页面。这就是两者的区别。
说完创建套接字,接下来来谈谈bind()
bind()的底层是sys_bind,函数也很简单,根据socket()返回的打开文件号,找到对应的socket数据结构。在这里先放一张与socket相关的数据结构的联系图。
然后从sock通过指针ops找到相应的方法。有连接的和无连接的ops都指向unix_bind()。所以调用unix_bind()。unix_bind()比较复杂,一句话概括就是讲给定的socket和一个给定的地址捆绑。
接下来说说listen()
listen()和bind()差不多,都是先通过打开文件号找到socket数据结构,接着通过sock的ops指针调用相应的方法。无连接的ops指向空,有连接的ops指向unix_listen()。后面那个参数我们通常给的5,那个就是如果监听队列满了溢出,最多能够再监听5个。
接下来说accept()
Accept()只有有连接的通信模式才会有。我们再accept()的时候,要int一个c来接收返回值。为什么呢?这是因为,在accept()调用底层sys_accept()的时候,会自动创建另一个socket用来处理连接,返回的c就是一个打开文件号。这也就使得我们能够用一个socket来多次accept。下面来谈谈它的底层sys_accept().
还是老样子,先找到socket结构,接着如上文所说分配一个新的socket结构。再通过ops指向具体函数unix_accept()。当然,无连接的ops指向空。Accept()只分配了一个socket结构,但是并没有分配一个sock结构。很明显这个套接字并不完整。那么在什么时候它才完整呢?在connect的时候,connect()会分配一个sock结构并且带过来,并且与socket结构组合。
再来说说connect()
有连接的connect()和无连接的connect()看着像是一样的,但是他们有本质上的区别。我们先不谈底层。系统为收发数据提供了如下函数:send()/recv()、sendto()/recvfrom()、sendmsg()/recvmsg。一般来说,send()/recv()是提供给有连接的,sendto()/recvto()是提供给无连接的。有连接的情况下,连接建立以后就没有必要来回提供目的地以及目的地大小了,无连接的情况下就必须提供。但是在调用底层的时候,我们总是需要将用户空间的数据拷贝到系统空间,时间一长,这耗损的时间也不是个小数目。那么有什么办法优化呢?办法就是提前让内核记录下对方地址。无连接模式下,connect()的作用就是如此。有连接模式下,则是实际给对方发送一个请求连接的控制报文并且等待对方响应。
有了上面的不同,我们再依次来看看两种连接方式提供的不同方法,先从有连接模式开始。
sys_connect()还是老样子,通过sockfd找到socket,接着通过sock的ops找到处理方法。
无连接就比较麻烦,在连接的时候,会在服务器的sock上修改一个指针,这个指针指向客户端的sock,在每次连接的时候,都会检查这个指针,如果指针不是空,那就错误返回了。也就是说,如果要实现一对多,就不能够使用connect接着send偷懒,而是老老实实的sendto()。
close()和垃圾回收方法
close()的底层是sock_close(),这个函数先对插口可能实现的异步操作(处理连接请求、接受报文)带来的数据结构释放掉,接着再通过sock->ops调用unix_release()。
内核里有一个哈希表unix_socket_table放着每个socket的sock结构,那么首先释放掉它们,接着处理连接(被连接)的套接字,发出SIGIO信号。接下来就是对报文的处理。在创建套接字的时候我们给了它inode和dentry,这也要处理。在接下来就是处理通过socket收发的各种文件权限了。
我们可以通过sendmsg()把对已打开的文件授权给其他进程,发送报文后发送方会记账,接收方在接收后会销账。在发送的过程中,获得授权的用户是内核,接收后内核会把权限转交给目标进程。当然,迟早这些进程会关闭这些文件,但是有一种特殊情况我们需要考虑,这就涉及到了垃圾回收。
第一种情况,我们要关闭的socket的接受队列里有若干个关于文件访问的报文。当然,我们需要调用方法递减file结构的共享计数。这是正常情况
第二种情况,我们从进程A将要关闭的的socketA发送了一个报文,但是在进程B的socketB接收前,A就关闭了。下面有两个情景
第一种情景:
A通过sa把sa的访问权给B,此时已经递增了sa的file结构的共享计数,报文到了sb的接受队列。但是B还没有接收报文。
接下来A通过close关闭了sa,但是共享计数不为0,sa的数据结构没有释放掉
接下来B没有从sb接收报文但是关闭了sb。此时sb的共享计数是0,自动调用sock_close(),对所有接受队列的报文都free,sa所在的报文也被free,此时sa共享计数也为0,最后被关闭。正常。
第二种情景:
A通过sa把sa的访问权给B,此时已经递增了sa的file结构的共享计数,报文到了sb的接受队列。但是B还没有接收报文。
B也通过sb把sb的访问权给了A,此时已经递增了sb的file结构的共享计数,报文到了sa的接受队列。但是A还没有接收报文。
A通过close关闭了sa,但是共享计数不为0,sa没被释放。
B通过close关闭了sb,共享计数不为0,sb也没被释放。
第二种情况很明显产生了垃圾,那么接下来就来谈谈sock的垃圾回收。
垃圾回收是通过两次扫描完成的,扫描之前先把所有的sock都置为“孤儿”,第一次扫描是检查sock所对应的file的共享计数与sock结构体里的“帐”。如果文件共享计数大于帐目上的数,那么除了还未接受到的授权报文外至少还有一个进程,这就不是孤儿。如果文件共享计数==账目上的数,那就有可能有问题,因为这个sock已经没有真正意义上的用户来了。没问题的都放进一个队列里
有问题的仅仅是有可能有问题,举个例子,如果一个权限发给了多个进程,最后有一个进程接收了这个报文,那么这个sock就不是孤儿了。所以还要进行第二轮检查。
第二轮检查就从没问题的那个sock队列里,检查它们的报文,遇到权限队列进行一些处理,处理以后再回过头来看这些有可能是孤儿的sock。对于这些孤儿该怎么办呢?
扫描接受队列里的所有报文,只要发现了有访问授权的报文就将其集中到一个队列里,最后统一销毁。对于孤儿的sock,我们不需要太多的操作,共享计数为0就会自动销毁。