HXWfromDJTU/blog

Socket 编程 - tcp与udp socket

Opened this issue · 0 comments

简要理解

  • 简单定义

    • 网络上的两个程序通过一个双向链接实现数据的交换,这个连接的一段被称之为socket

    • socket的本质是一个编程接口,对TCP/IP进行了封装,TCP/IP也要提供可供开发者做网络开发的接口。

    • 一台主机上多个端口,对应着不同的应用服务,每个服务都打开一个socket,并且绑定到一个端口上。

  • socket做基础,应用层负责多样性

    • 一台主机就像一个布满插座的房间,有的插座提供的是直流电,有的插座提供的是交流电,有的插座提供的是一个电视信号,用户通过插头插到不同的插座,就可以得到不同的服务。
    • HTTP提供了封装或者显示数据的具体形式,socket是发动机,提供了网络通信的能力。

  • socket 与 fd

    • socket 是一套用于Unix进程间通信的api。IP + port 等于网络socket的地址

    • 根据UNIX中一切皆是文件的哲学,常规意义的文件、目录、管道、socket都可以看成文件。例如,我们通常也认为TCPSocket是一个文件流。

    • fd是内核提供给用户安全操作文件的标识,标识符而不像指针,你不能进行修改,只能以当做参数传递给系统不同的api,告知系统该处理哪些文件。写入和读出,也是通过对文件描述符进行readwrite操作。

    • Socket 是一个文件,那对应就有文件描述符。每一个进程都有一个数据结构 task_struct,里面指向一个文件描述符数组,来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数,是这个数组的下标。

    • Socket 对应的文件 inode不是保存在物理硬盘上,而是存在于内存中。

      详细的fd解释,请参考另一篇[笔记](https://github.com/HXWfromDJTU/blog/issues/12)

  • 端到端通信

    Socket 是网络层上的一个概念,进行的是端到端的通信。既不能够感知到应用层是什么应用,也不能感知到中间将会经过多少局域网、路由器。因而能够设置的参数只是网络层传输层相关的参数。

    传输层协议 网际协议版本 数据格式
    TCP IPV4/6 SOCK_STREAM
    UDP IPV4/6 SOCK_DGRAM

    例如我们熟悉的nodejs中创建socket连接的参数就能看出

    对于 TCP 连接,可用的 options 有:
        port <number> 必须。套接字要连接的端口。
        host <string> 套接字要连接的主机。默认值: 'localhost'。
        localAddress <string> 套接字要连接的本地地址。
        localPort <number> 套接字要连接的本地端口。
        family <number> IP 栈的版本。必须为 4、 6 或 0。0 值表示允许 IPv4 和 IPv6 地址。默认值: 0。
        hints <number> 可选的 dns.lookup() 提示。
        lookup <Function> 自定义的查找函数。默认值: dns.lookup()。
    
    对于diagram(udp)的 options <Object> 允许的选项是:
        type <string> 套接字族. 必须是 'udp4' 或 'udp6'。必需填。
        reuseAddr <boolean> 若设置为 true,则 socket.bind() 会重用地址,即使另一个进程已经在其上面绑定了一个套接字。默认值: false。
        ipv6Only <boolean> 将 ipv6Only 设置为 true 将会禁用双栈支持,即绑定到地址 :: 不会使 0.0.0.0 绑定。默认值: false。
        recvBufferSize <number> 设置 SO_RCVBUF 套接字值。
        sendBufferSize <number> 设置 SO_SNDBUF 套接字值。
        lookup <Function> 自定义的查询函数。默认值: dns.lookup()。
    
    // TCP
    var net = require("net");
    var server = net.createServer(function(socket){
        console.log('someone connects');
    })
    
    // UDP
    const dgram = require('dgram');
    const server = dgram.createSocket('udp4');

    nodejs - socket 文档传送门
    👉 tcp
    👉 udp

TCP socket连接场景

根据连接启动的方式以及本地套接字要连接的目标,套接字之间的连接过程可以分为三个步骤:服务器监听客户端请求连接确认

  • 服务器监听

    • 监听端口
      一般是先调用bind函数,给这个Socket赋予一个IP地址与端口。
      // node.js tcp监听
      server.listen(8000, function(){
       console.log("Creat server on http://127.0.0.1:8000/");
      })
      监听后,服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态。
  • 客户端请求

    是指由客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。

      // node.js 创建tcp连接
       const net = require('net');
       const client = net.createConnection({ port: 8124 }, () => {
       // 'connect' 监听器
       console.log('已连接到服务器');
       client.write('你好世界!\r\n');
      });
  • 连接确认

    是指当服务器端套接字监听客户端套接字的连接请求,它就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发给客户端,一旦客户端确认了此描述,连接就建立好了。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

    • socket 与 队列

      在内核中,为每个Socket维护两个队列

      • 一个是已经建立连接的队列,处于established状态。
      • 一个是还没有完全建立连接的队列,处于syn_rcvd的状态。
    • 监听socket 与 已连接socket

      在服务等待的时候,客户端仍可以通过IP地址端口号发起连接,并开始三次握手,内核会为其分配一个临时的端口。直到握手成功,服务端使用accept函数返回另一个socket进行处理。 --- 《趣谈网络协议》

      用于监听的socket与用于数据传输的socket是两个不同的socket。这里通常称之为监听socket已连接socket

连接一旦建立,双方的socket之间的读写readwrite就和在一台机器上俩进程之间的读写没有区别,正如一开始说的,socket是感知不到中间经过多少路由器和电缆线路的。

发送队列 与 接收队列

UDP socket连接场景

对于UDP来说,是无连接、无握手过程的,也就不存在了上面👆TCP连接过程listenconnect。但是其仍需要一个IP和端口号。

  • 服务端监听

    // node.js 使用 dgram 模块启动 UDP 服务
    const dgram = require('dgram');
    const server = dgram.createSocket('udp4');
    
    // 服务器监听 0.0.0.0:41234
    server.bind(41234);
  • 客户端连接

    • 无连接
      UDP 是没有维护连接状态的,因而不需要每对连接建立一组 Socket,而是只要有一个 Socket,就能够和多个客户端通信。也正是因为没有连接状态,每次通信的时候,都调用 sendtorecvfrom,都可以传入 IP 地址和端口。
      // node.js 客户端通过UDP发送数据
      const dgram = require('dgram');
      const client = dgram.createSocket('udp4');
      const msg = Buffer.from('hello world');
      const port = 41234;
      const host = '255.255.255.255';
      
      client.bind(function(){
          client.setBroadcast(true);
      
          // 每次通信,都可以传入 IP 地址 和 端口
          client.send(msg, port, host, function(err){
              if(err) throw err;
              console.log('msg has been sent');
              client.close();
          });
      });

并发连接问题

TCP最大连接数

  • socket 四元组的限制

    每一个TCP可以用一个四元组来唯一确定,也就是

    <source_IP, source_PORT, target_IP, target_PORT>
    

    通常服务端启动服务后,IPPort就不再变化,但是可以承接N多个客户端的请求。这里的N是由Source_IP数目2^32个,与port数目2^16个.搭配起来的话就是2^48个,但实际上远远达不到。

    备注: IPV_4下 共有32位,则最多2^32个。TCP与UDP存储port的字段一共16位,所以最多2^16

  • 文件描述符限制

    按照上文的理解,每一个socket都被OS当做一个文件处理,那么就有存在打开文件数的限制。

    # 查看最大打开文件数
    $ ulimit -n
    
    # 设置最大打开文件数
    $ ulimit -n <file-num>

    这里的-n参数标明修改的是单个进程可以打开的文件数

    其他的参数这里也一并简单了解一下(参考文档👉)

       数据段长度:ulimit -d unlimited
       最大内存大小:ulimit -m unlimited
       堆栈大小:ulimit -s unlimited
       CPU 时间:ulimit -t unlimited
       虚拟内存:ulimit -v unlimited    
  • 内存的限制

    每一个socket都有对应的inode存储在内存中,然而计算机的内存是有限的。

解决办法

  • 创建多进程

    Linux创建一个子进程的操作称为fork, 进程复制的主要涉及几样东西

    • 把文件描述符列表全部拷贝一遍。
    • 也会复制内存空间。
    • 复制当前代码执行的位置。

    执行了fork后,父子进程理论上是完全一样的。仅能通过fork返回值来区分,自己到底是父进程还是子进程

      // node 创建子进程的命令
      const cp = require('child_process')
    
      // 启动子进程来执行命令
      cp.spawn('node', ['index.js'])
      // 启动了子进程,并通过回调获得信息
      cp.exec('node index.js', (err, studio, stderr) => {
          // get some message
      })
      // 启动子进程来执行可执行文件
      cp.execFile('index.js',  (err, studio, stderr) => {
          // get some message
      })
      // 仅需指定执行文件模块
      cp.fork('index.js')
  • 创建多线程

    进程的创建与销毁开销过大,则可以考虑使用轻量级得多的多线程,区别在于

    • 共用文件描述符
    • 共用进程空间
    • 新的连接也可以直接通过已连接的socket来处理请求,从而达到并发表处理的目的。
    const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
    
    return new Promise((resolve, reject) => {
        const worker = new Worker(__filename, {
          workerData: script
        });
        worker.on('message', resolve);
        worker.on('error', reject);
        worker.on('exit', (code) => {
          if (code !== 0)
            reject(new Error(`Worker stopped with exit code ${code}`));
        });
      });

    示例代码使用nodeworker_threads,感兴趣的推荐这篇文章👉

  • I/O 多路复用

    无论多么厉害的一台机器,同时维护超过1w个进程,OS是无法承受的,这就是常说的C10K问题。此时I/O多路复用就出现了。有以下优点:

    • 使用监听的形式,Socket 数据增加的时候,效率不会大幅度降低
    • 能够同时监听的 Socket 的数目能达到系统定义的、进程打开的最大文件描述符个数

    更多关于多路复用的问题,请关注另一篇笔记👉

参考文章

趣谈网络协议 - 套接字Socket