闲聊网络通信原理、openjdk-socket源码分析

1.概述

最近没有写想法,但本着长时间不写会生疏的说法,还是简单写一篇技术文章。这篇主要聊聊网络方面知识,涉及面广,但是不深 a。OK,话不多说,下文主要就是先介绍网络工作原理,然后从语言层面进行具体的实现分析。希望对大家有所帮助。

2.网络现状

互联网是离不开网络的(说了句废话)。比如IM、游戏、浏览器网页等场景,都需要网络的支持。网络依赖协议,没有协议是无法进行网络传输的。因为别人并不知道你发送的是什么妖魔鬼怪。

目前用的最多的就是IP协议。传输层一般是TCP和UDP。游戏场景可能用UDP多一点,毕竟TCP为数据传输可靠以及网络链路做出了很大的让步。这也是理所应当的。正是因为多种场景才会出现多个协议。如果一个TCP能解决所有问题,那有它一个就够了。应用层有个通用的HTTP协议。

3.网络工作原理

我们今天就以浏览器访问网页的用例来聊聊网络是如何连接工作的。
浏览器(就是一个软件)通过多个进程去工作。其中一个就是用来进行网络通信的进程(具体是不是这样不清楚,但大概差不多),当我们从输入框输入网站地址的并点击访问的时候,整个网络通信就开始了。

生成HTTP请求

对于请求,大家应该都不陌生,进行网络通信的时候,都需要一个对应协议的请求。浏览器在应用层使用的是HTTP协议。为什么是HTTP,为什么需要协议。一方面为了让多语言可以通信(比如浏览器一般是C++,服务器可以是JAVA或者其他)。另一方面也是为了根据需求需要一些记录。也就是每个数据包公有的部分。

比如HTTP请求第一行就是如下数据。我们可以通过方法判断什么请求,可以通过URI区分请求的资源路径等。

方法 空格 URI 空格 HTTP版本

当然,我们也可以实现自己的应用层协议满足我们的场景。虽然使用HTTP可以满足任何场景,但一些多余数据会影响网络通信性能。目前的大部分RPC调用都是自定义协议。一些游戏场景也是如此。对于应用层的开发,我们能做的可能就只有改应用层协议了。

或许有些人说可以基于UDP去改装一个传输层的协议。但这其实还是依赖UDP(比如KCP协议)。

这里所说的协议就是应用层一些。可以这么说:哪一层的协议就会在对应层进行处理。

  • 如果是传输层协议TCP。那么操作系统的TCP模块收到包后,进行模块内逻辑处理完成后,砍掉TCP头部(TCP协议部分),将数据丢给应用层。
  • 如果应用层协议,我们的应用层服务器收到数据包之后,会进行处理,最后将所有数据封装丢给我们去做逻辑。
一个数据包,在我们想要发出去之后,他会进行层层包装。然后转换成电信号,传输到对端。对端又会层层解包装。感觉就像是一个入栈出栈的过程。

查询解析域名

首先浏览器进程会根据输入的域名去获取对应域名的IP地址。这个过程叫做域名解析(DNS解析)。其实就是从本机配置,缓存或者DNS服务器获取域名对应的IP。当然DNS服务器也支持通过IP查域名。这是一个可逆的过程。详细的大家可以去了解域名解析过程。我们目前知道目的和结果就行。

TCP模块

构造好应用层请求数据包,并查询了目的地。浏览器进程会委托TCP模块将数据包发送出去。
这里所说的协议栈描述的就是操作系统的TCP/IP模块。因为是操作系统的模块,所以我们一般无法直接修改它的逻辑。我们能做的最多的就是通过操作系统提供的参数配置进行优化传输。这个读者可以详细了解。


 <center>图片来自 《网络是怎么连接的》</center>

使用TCP发送数据包,需要建立连接。经过三次握手之后才能收发数据,结束之后,也是需要进行四次挥手的。
TCP模块会为应用层数据封装一个TCP头部。然后丢给IP模块。TCP建立连接的方式也是如此。相当于发一个不带应用层数据的TCP头部,并标志响应的字段告诉对端这是一个建立连接的请求包。

具体的TCP模块内容,大家可以去看看内核TCP源码。过程十分复杂(毕竟工业落地要考虑各种问题),但是大概了解应该还行。或者可以看看相关paper。

IP模块

IP模块负责的事情也比较多。因为经过IP模块处理后,网卡只需要将包传输出去即可。
IP模块主要就是添加IP头部和MAC头部信息。IP头部主要包括的就是将包发往目地的控制信息。

IP模块不会关注上层协议的数据。也就是IP模块把UDP包和TCP包看作一整块二进制数据。他只负责就这些数据发出去。至于包是否有序或者是否丢失,它并不关注。这也其实符合传统设计原则。各模块层应该是互相解耦的设计。

IP头部

IP头部主要信息就是包的目标地址,和源地址。目标地址是上层告知的,也就是TCP模块(TCP从应用层得到)。
源地址是根据目标地址获取到的。为什么这样说?其实数据包是通过网卡发送的。一个网卡对应一个IP地址,我们需要根据目标地址查询该数据包应该从哪个网卡发送出去。

这个查询过程依赖的就是IP路由表。


如上图,IP模块会根据目标地址和网络目标栏匹配,如果没有匹配的则走0.0.0.0,也就是默认地址。而后面接口对应的地址就是网卡的IP地址。

IP头部的其他数据就暂且不说。

MAC头部

每个网卡都会有一个MAC地址。在生产的时候就会写入网卡的ROM中。
我们知道了网卡就知道了源MAC地址。
目标MAC地址是通过ARP协议,依赖网关查询来的。具体ARP实现原理大家可以自行学习。

大家可能有个疑问,为什么IP模块会负责MAC地址的封住。毕竟MAC头部应该是由以太网负责的。其实就是为了降低网卡的工作。毕竟IP模块是可以完成这些工作的。如果后续有其他协议替换IP协议。网卡模块如此设计也是可以直接支持的。

封装成功后,会依赖网卡将数据转化为电信号发送出去。具体的发送过程就不说了,涉及物理层,细节可以大家去了解。

目前的网络传输依赖的都是路由器。并且目前的路由器已经继承了交换机和集线器的功能。所以大家直接了解路由器的工作原理即可。

IP分片

这里我们需要注意的一点就是路由器是基于IP协议去实现的。所以路由器的IP模块和上述计算机的类似。路由器收到包后会根据IP地址确定包的下一跳。并且根据一些限制可能会进行包的分片。也就是IP分片。是否分片主要取决于包的配置(是否允许)以及目标路由器能接受包的大限制(MTU),当然IP分片只会降低传输性能。所以,我们平时应该避免。

我们不能只保证应用层包小于MTU即可。因为这个MTU还包括了TCP以及IP头部。所以我们还要减去这两个部分。尤其对性能有要求的场景。我们需要重点关注。


 <center>图片来自 《网络是怎么连接的》</center>
上图就是一个网路包在网络传输的过程。

接收网络包

其实接收只不过是发送的逆过程。

网卡将电信号转转化为数字信号后,发送给MAC模块。MAC模块主要就是校验包是不是发给自己,并且校验是否发生错误。否则都会丢弃数据包。完成之后会将数据包放入缓冲区,然后通知操作系统,是通过中断的方式。

CPU接收到中断信号,会切换到中断处理程序,中断处理程序会调用网卡驱动程序,从网卡缓冲区读取数据,然后根据数据的协议将其发送给对应的模块。一般就是IP模块。

然后就是从IP传到TCP在传到应用服务器。最后传到业务层。

4.源码分析

接下来我主要从代码分析我们如何进行网络通信。其实用C代码更好理解,但是我主要是做JAVA,所以我们主要研究一些JAVA是如何做封装的。

代码层面,网络通信依赖socket。这里我们直接上升到的是协议栈的socket(这里说的是传输层)。所谓协议栈的socket,其实就是协议栈一块内存空间。结构化的存储了一些通信所需的控制信息。比如IP和端口号,通信状态等。协议栈依赖这块内存空间进行数据的传输。

JAVA中的socket也是如此,只不过他不会直接对应协议栈的socket。而是抽象出来一层。最后还是会将信息对接到协议栈。这层抽象保证了我们在java中的一些操作,会直接映射到底层协议。(java就是一层更高层次的抽象)

OK,了解了socket。我们就可以深入源码啦!

Server:
ServerSocket ss =new ServerSocket(10000);
Socket s = ss.accept();
Client:
Socket s = new Socket("localhost",10000);

上面是一个简单的例子。服务端初始化一个ServerSocket。并调用accept方法,即可监听客户端连接。
客户端初始化Socket,就已经向服务端发器连接。

ServerSocket构造过程

try {
    SecurityManager security = System.getSecurityManager();
    if (security != null)
        security.checkListen(epoint.getPort());
    getImpl().bind(epoint.getAddress(), epoint.getPort());
    getImpl().listen(backlog);
    bound = true;
}

初始化ServerSocket对象的时候,会创建SocketImpl。然后调用bind方法,再调用listen方法。

其实逻辑很简单。真正和底层通信的并不是ServerSocket,而是SocketImpl。

getImpl方法

SocketImpl getImpl() throws SocketException {
    if (!created)
        createImpl();
    return impl;
}
void createImpl() throws SocketException {
    if (impl == null)
        setImpl();
    try {
        impl.create(true);
        created = true;
    } catch (IOException e) {
        throw new SocketException(e.getMessage());
    }
}

如果SocketImpl为空则创建,否则调用impl.create方法创建一个sokcet。

fd = new FileDescriptor();
socketCreate(true);

server socket创建逻辑就这么两行代码。

1.创建一个FileDescriptor。用来记录底层socket对应的文件描述符。底层socket创建之后会返回该socket的fd。底层jni创建socket之后,会将返回的fd赋值给上层new的FileDescriptor。具体实现是直接改变对象的内存地址进行fd赋值。细节大家可以自行研究。

2.参数true代表时serversocket。

socketCreate方法

if ((fd = socket(domain, type, 0)) == -1) {
    /* note: if you run out of fds, you may not be able to load
     * the exception class, and get a NoClassDefFoundError
     * instead.
     */
    NET_ThrowNew(env, errno, "can't create socket");
    return;
}
...
if (isServer) {
    int arg = 1;
    SET_NONBLOCKING(fd);
    if (NET_SetSockOpt(fd, SOL_SOCKET, SO_REUSEADDR, (char*)&arg,
                   sizeof(arg)) < 0) {
        NET_ThrowNew(env, errno, "cannot set SO_REUSEADDR");
        close(fd);
        return;
    }
}
(*env)->SetIntField(env, fdObj, IO_fd_fdID, fd);

上面截取了部分核心代码。

首先调用socket方法创建一个tcp套接字。domain为ip版本。linux的socket函数会返回对应的文件描述符。server socket会设置非阻塞。

SetIntField方法就是上面说的为我们创建的FileDescriptor赋值。fdObj就是我们创建的FileDescriptor对象。具体细节不多说。

bind方法

protected synchronized void bind(InetAddress address, int lport)
    throws IOException
{
   synchronized (fdLock) {
        if (!closePending && (socket == null || !socket.isBound())) {
            NetHooks.beforeTcpBind(fd, address, lport);
        }
    }
    socketBind(address, lport);
    if (socket != null)
        socket.setBound();
    if (serverSocket != null)
        serverSocket.setBound();
}

根据创建好的InetAddress和本地端口,去调用底层socketBind方法。

/* bind */
if (NET_InetAddressToSockaddr(env, iaObj, localport, &sa,
                              &len, JNI_TRUE) != 0) {
    return;
}

if (NET_Bind(fd, &sa, len) < 0) {
    if (errno == EADDRINUSE || errno == EADDRNOTAVAIL ||
        errno == EPERM || errno == EACCES) {
        NET_ThrowByNameWithLastError(env, JNU_JAVANETPKG "BindException",
                       "Bind failed");
    } else {
        JNU_ThrowByNameWithMessageAndLastError
            (env, JNU_JAVANETPKG "SocketException", "Bind failed");
    }
    return;
}

底层绑定方法部分代码如上。

1.NET_InetAddressToSockaddr,将java层的InetAddress数据和端口传入到sa中。其实就是将java对象转化为c的结构体。

2.调用NET_Bind进行绑定。

3.成功之后设置java层的localport和address。当然,如果我们传递的port是0,os会帮我么生成一个端口。

listen方法

该方法就很简单了,直接调用底层listen。

if (listen(fd, count) == -1) {
    JNU_ThrowByNameWithMessageAndLastError
        (env, JNU_JAVANETPKG "SocketException", "Listen failed");
}

count参数代表同时能处理的最大连接。
到这个阶段socket的创建就完成了。需要注意,调用了listen并不是说该socket可以接受连接了。accept才是。

accept方法

public Socket accept() throws IOException {
    if (isClosed())
        throw new SocketException("Socket is closed");
    if (!isBound())
        throw new SocketException("Socket is not bound yet");
    Socket s = new Socket((SocketImpl) null);
    implAccept(s);
    return s;
}

implAccept方法其实就是为Socket创建一个SocketImpl。

怎么理解上面的话呢?

其实每一个客户端和服务器建立连接。服务端都会拷贝一个socket(这里说的是协议层),通过上面的介绍,底层的socket和java层的SocketImpl进行映射。java层的socket封装了SocketImpl而已。所以我们要返回一个有效的java层socket,就需要填充其中的SocketImpl。也就是调用下面的accept方法。

//该方法的目的就是填充socket的SocketImpl对象。
protected void accept(SocketImpl s) throws IOException {
    acquireFD();
    try {
        socketAccept(s);
    } finally {
        releaseFD();
    }
}

acquireFD主要就是对fd的引用计数。使用完成,需要调用releaseFD。
这里我们需要注意。

socketAccept方法

for (;;) {
    int ret;
    jlong currNanoTime;
    /* 省略部分*/
    newfd = NET_Accept(fd, &sa.sa, &slen);

    /* connection accepted */
    if (newfd >= 0) {
        SET_BLOCKING(newfd);
        break;
    }

    /* non (ECONNABORTED or EWOULDBLOCK or EAGAIN) error */
    if (!(errno == ECONNABORTED || errno == EWOULDBLOCK || errno == EAGAIN)) {
        break;
    }

    /* ECONNABORTED or EWOULDBLOCK or EAGAIN error so adjust timeout if there is one. */
    if (nanoTimeout >= NET_NSEC_PER_MSEC) {
        currNanoTime = JVM_NanoTime(env, 0);
        nanoTimeout -= (currNanoTime - prevNanoTime);
        if (nanoTimeout < NET_NSEC_PER_MSEC) {
            JNU_ThrowByName(env, JNU_JAVANETPKG "SocketTimeoutException",
                    "Accept timed out");
            return;
        }
        prevNanoTime = currNanoTime;
    }
socketFdObj = (*env)->GetObjectField(env, socket, psi_fdID);
(*env)->SetIntField(env, socketFdObj, IO_fd_fdID, newfd);


(*env)->SetObjectField(env, socket, psi_addressID, socketAddressObj);
(*env)->SetIntField(env, socket, psi_portID, port);
/* also fill up the local port information */
 port = (*env)->GetIntField(env, this, psi_localportID);
(*env)->SetIntField(env, socket, psi_localportID, port);

上面代码为了简单,省略一部分,主要逻辑如下:

1.调用NET_Accept方法获取连接请求。

2.如果连接合法,设置为阻塞,然后break。非ECONNABORTED or EWOULDBLOCK or EAGAIN的错误直接break

3.如果超时,返回。

4.循环外,如果fd合法,也就是新连接合法,为SocketImpl赋值。包括远程端口,本地端口以及address。

**这里需要注意一点,上述代码accept后只会返回一个新socket的fd。
那么这个新的socket如何来?**

其实新的socket是从监听的socket拷贝而来。所以对于每一条新的连接。服务端都会有个socket与之对应。
该socket的本地端口、地址和监听socket的一致。只不过它加入了远程socket的地址和端口。这样,如果有数据到达之后,TCP是可以确定将数据丢给哪个socket的缓冲区。

从上面源码可以看出。java层面的网络,底层都是调用c。c去通过系统调用创建TCP的socket。并返回创建的信息。具体创建过程我们不需要关注。但是基本原理我们应该清楚。

5.多说两句

上面源码只是简单分析了socket的原理,做过java的人,肯定是不会直接使用socket进行编程的。对于网络来讲,java中有一些优秀的框架。比如netty。其基于jdk1.4的nio。实现高性能io模型。在linux服务器中,可以使用epoll模型实现高效的网络通信。

无论使用何种网络模型,还是要根据业务来讲。很多人对epoll吹的可能有点过。其实说白了,epoll也只是os的一个补丁而已。如果当初设计os的时候能考虑到高并发问题,也就不会有它的事了。毕竟频繁调用epoll_ctl对性能影响还是有的。举个不恰当的例子:如果网络没有了时延,你还会用epoll吗?

当然epoll在当前还是比较有效的。后面我会详细介绍epoll是如何工作,保证高性能。

6.总结

本文对网络编程进行了简单的描述。可以让大家对网络原理有个大概的认识。虽然我们日常开发中并用不到这些,但是我们用到的框架也是由这方面封装出来的。
框架其实就是对基础库更好的使用,尽量做出优化。便于更快编写出高效的程序。

阅读 174

推荐阅读