《Working with TCP Sockets》读书笔记

Posted on October 20, 2013

Socket是TCP/IP协议的API.

TCP是网络协议, Socket是实现协议的一种技术。


前言

Berkeley套接字API是一种编程API,运作在实际的协议实现之上。它关注的是连接两个端点(endpoint)共享数据,而非处理分组和序列号。


第1章 建立套接字

  • ruby 中使用socket需要require 'socket' 其中包括了各种用于TCP套接字、UDP套接字的类,以及必要的基本类型

  • 创建socket

      socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
    
      socket = Socket.new(:INET6, :STREAM) #借助ruby语法糖
    

    Socket::AF_INET 表示ipv4

    Socket::SOCK_STREAM :STREAM表示tcp数据流, Socket::SOCK_DGRAM UDP数据流报

    :INET6 表示ipv6

  • 一个socket的ip和port组合必须唯一,同一个port可以有不同ip的socket同时侦听,甚至是一个IPV4, 一个IPV6


第2章 建立连接

  • 创建的socket的角色将是以下2者之一,如果要成功连接,二者缺一不可

    • 发起者(initiator)
    • 侦听者(listener)

第3章 服务器生命周期

  • 服务器socket用于侦听,其生命周期:

    1. 创建
    2. 绑定
    3. 侦听
    4. 接受
    5. 关闭
  • 绑定:

    1. 首先用c结构体创建侦听地址: addr = Socket.pack_sockaddr_in(4481, '0.0.0.0')

    2. 执行绑定 socket.bind(addr)

    服务器端口使用:

    1. 不要使用0~1024 之间的端口,这是熟知端口

    2. 不要使用49 000~65 535 之间的端口,这是临时端口

    3. 1025~48 999之间端口的使用是一视同仁的

    ip使用:

    • 127.0.0.1 是环回地址,0.0.0.0 是本机所有地址
  • 侦听

    socket.listen(max_pending_count) 实现侦听,参数是字能够容纳的待处理(pending)的最大连接数

    待处理的连接列表被称作侦听队列

    如果侦听队列已满,那么客户端(???为什么是客户端)将会产生Errno::ECONNREFUSED

    Socket::SOMAXCONN 可以获知当前所允许的最大的侦听队列长度

    可以使用server.listen(Socket::SOMAXCONN)将侦听队列长度设置为允许的最大值

  • 接受

    server.accept 以阻塞的方式接受连接请求,返回一个数组[connection, Addrinfo]

    connection表示已建立的连接,其实是Socket的实例,这表明每个连接都由一个全新的Socket对象描述,这样服务器套接字就可以保持不变,不停地接受新的连接。

    第二个元素Addrinfo是一个Ruby类,描述了一台主机及其端口号,这里表示客户端的地址

    连接地址

    connection.local_address 获得连接的本地地址, Addrinfo的实例

    connection.remote_address 获得连接的客户端地址, Addrinfo的实例

    accept循环:

      loop do
        connection, _ = server.accept
        # 处理连接。
        connection.close
      end
    
  • 关闭连接

    connection.close

    Socket是双向通信,可以只关闭其中一个通道

    关闭写:connection.close_write, 会发送一个EOF到套接字的另一端

    关闭读:connection.close_read,

    close 和 close_write/read 区别

    连接副本 可以使用Socket#dup创建文件描述符的副本。这实际上是在操作系统层面上利用dup(2)复制了底层的文件描述符,或者Process.fork的新进程也会出现文件描述符的副本

    close_writeclose_read方法在底层都利用了shutdown(2)。同close(2)明显不同的是:即便是存在着连接的副本,shutdown(2)也可以完全关闭该连接的某一部分。

    close不会关闭连接副本

    shutdown只是关闭了通信,Socket的资源并没有回收,所以每个Socket还必须close以结束生命周期

  • 为什么要关闭:

    每个连接都是一个文件, 进程打开文件有限制; 虽然GC会清理不用的连接

      Process. getrlimit(:NOFILE)
      [
          [0] 4864, #软限制(用 户配置的设置)
          [1] 9223372036854775807 #硬限制(系统限制)
      ]
    

    将限制设置到最大值,可以使用Process.setrlimit (Process.getrlimit(:NOFILE)[1])

      require 'socket'
      sockets = [];
      loop do
        socket =  Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
        sockets<<socket
        puts socket.fileno
      end
    
      9
      10
      ...
      4861
      4862
      4863
      Errno::EMFILE: Too many open files - socket(2)
    

    系统命令ulimit -a 也可以查看此限制:

     % ulimit -a
    -t: cpu time (seconds)              unlimited
    -f: file size (blocks)              unlimited
    -d: data seg size (kbytes)          unlimited
    -s: stack size (kbytes)             8192
    -c: core file size (blocks)         0
    -v: address space (kbytes)          unlimited
    -l: locked-in-memory size (kbytes)  unlimited
    -u: processes                       709
    -n: file descriptors                4864  (文件描述符打开数量)
    

    此限制的对象是一个用户

  • ruby包装器

      require 'socket'
      server = TCPServer.new(4481)
    

    以上代码实现了创建,绑定和侦听三个步骤,server是TCPServer的实例,但是接口和Socket基本一致

    明显的不同是 只返回连接,而不返回remote_address

    Ruby默认将侦听队列长度设置为5, 如果需要更长的侦听队列,可以调用TCPServer#listen

      #返回2个server,一个ipv4,一个ipv6
      servers = Socket.tcp_server_sockets(4481)
    

    连接处理

      require 'socket'
      #创建侦听套接字。
      server = TCPServer.new(4481)
      # 进入无限循环接受并处理连接。
      Socket.accept_loop(server) do |connection|
        # 处理连接。
        connection.close
      end
    

    连接仍徐结束时手动关闭

    可以向它传递多个侦听套接字, 如 Socket.accept_loop(servers)

    Socket.tcp_server_sockets和Socket.accept_loop的合体:

      Socket.tcp_server_loop(4481) do |connection|
        # 处理连接。
        connection.close
      end
    

第4章 客户端生命周期

  • 客户端是请求的发起者,其生命周期包括:

    1. 创建(和服务器创建一致)
    2. 绑定(客户端如果不bind,将使用临时端口,建议是不显式绑定)
    3. 连接
    4. 关闭
  • 连接

      remote_addr = Socket.pack_sockaddr_in(80, 'google.com')
      socket.connect(remote_addr)
    

    连接超时会抛出异常Errno::ETIMEDOUT,超时原因可能是connect一个不存在在端点,或者该server还没有开始accept

  • ruby包装

      socket = TCPSocket.new('google.com', 80)
    
      #等价于
      client = Socket.tcp('google.com', 80)
    
      #附加连接处理
      Socket.tcp('google.com', 80) do |connection|
        connection.write"GET / HTTP/1.1\r\n"
        connection.close
      end
    

第5章 交换数据

TCP/IP数据被编码为分组,分组是有边界的

同时TCP具有的性质,流没有边界的概念,客户端分批发送,服务器也是将其作为一份数据接收。并且次序会有保证


第6章 套接字读操作

  • connection.read 简单读取,以EOF标志流结束

      Socket.tcp_server_loop(4481) do |connection|
        # 从连接中读取数据最简单的方法。
        puts connection.read
        # 完成读取之后关闭连接。让客户端知道不用再等待数据返回。
        connection.close
      end
    

    以下方式的客户端发送数据,tail将不会停止发送数据(不发送EOF)因此管道一直是打开的

    tail -f /var/log/system.log | nc localhost 4481

  • 设置读取长度 connection.read(读取长度)

    凑齐读取长度,或者遇到EOF,read返回

    不足长度且没遇到EOF,仍然不会返回

      Socket.tcp_server_loop(4481) do |connection|
        while data = connection.read(1024) do
          puts data
        end
        connection.close
      end
    
  • 解决read阻塞:

    1. 客户端发送完数据后发送EOF 事件:connection.close

    2. 部分读取

      readpartial 并不会阻塞, 而是立刻返回可用的数据。调用readpartial时,你必须传递一个整数作为参数,来指定最大的长度,只要有数据,readpartial就会将其返回,即便是小于最大长度。

      当接收到EOF时,read仅仅是返回,而readpartial则会产生一个EOFError异常

       Socket.tcp_server_loop(4481) do |connection|
         begin
           # 每次读取1024或更少。
           while data = connection.readpartial(1024) do
             puts data
           end
         rescue EOFError
         end
         connection.close
       end
      
  • read(max_count)readpartial(max_count)的区别

    read 阻塞,只在数据达到max_count或者读到EOF才返回

    readpartial只在没有任何数据时会阻塞,当有数据时不阻塞,有数据就返回,一次最多max_count,遇到EOF抛出异常


第7章 套接字写操作

connection.write('Welcome!')


第8章 缓 冲

写缓冲

  • 应用程序和实际网络硬件之间有一个写缓冲层

    write 返回成功只是说明数据交给了缓冲层,缓冲层可能立即发送,也可能出于性能考虑,进行合并发送

    TCP套接字默认将sync设置为true。这就跳过了Ruby的内部缓冲 否则就又要多出一个缓冲层了

  • 该写入多少数据?

    通常情况下,获得最佳性能的方法是一口气写入所有的数据,让内核决定如何对数据进行结合

读缓冲

  • 每次read ruby程序接受的数据量可能大于指定的长度,以备下次read使用

  • 该读取多少数据

    如果设置较大,内核需要分配较大内存,造成资源浪费

    如果设置较小,需要读取多次,增加系统调用次数,增大开销

    ruby的各种web server都是用16KB的读取长度


第9章 第一个客户端/服务器


第10章 套接字选项

套接字选项是一种配置特定系统下套接字行为的低层手法

因为涉及低层设置,所以Ruby并没有为这方面的系统调用提供便捷的包装器

  • 获得一个socket的类型

    1. SO_TYPE 区别tcp还是udp

       socket = TCPSocket.new('google.com', 80)
       # 获得一个描述套接字类型的Socket::Option实例。
       opt = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_TYPE) #返回Socket::Option实例
       # opt = socket.getsockopt(:SOCKET, :TYPE) 使用ruby符号
       # 将描述该选项的整数值同存储在Socket::SOCK_STREAM中的整数值进行比较。
       opt.int == Socket::SOCK_STREAM #=> true
       opt.int == Socket::SOCK_DGRAM #=> false
      
    2. SO_REUSE_ADDR

      TIME_WAIT状态: write后,缓冲区里的数据还未发送完,内核会保持连接以发送数据,此时即处于该状态

      如果关闭一个尚有数据未处理的服务器并立刻将同一个地址绑定到另一个套接字上( 比如重启服务器), 则会引发一个Errno::EADDRINUSE

      设置SO_REUSE_ADDR可以绕过这个问题,使你可以绑定到一个处于TIME_WAIT状态的套接字所使用的地址上

       server = TCPServer.new('localhost', 4481)
       server.setsockopt(:SOCKET, :REUSEADDR, true)
       server.getsockopt(:SOCKET, :REUSEADDR) #=> true
      

      TCPServer.new、Socket.tcp_server_loop及其类似的方法默认都打开了此选项。


第11章 非阻塞式IO

  • read_nonblock(max_count) 完全非阻塞读取,和readpartial唯一的不同是read_nonblock在没有数据可读时,抛出Errno::EAGAIN异常,而readpartial会阻塞

      loop do
        begin
          puts connection.read_nonblock(4096)
        rescue Errno::EAGAIN
          retry
        rescue EOFError
          break
        end
      end
    

    更好的方式是使用优雅的IO.select,它会引起阻塞,直到第一个参数里的Socket可读

      begin
        connection.read_nonblock(4096)
      rescue Errno::EAGAIN
        IO.select([connection])
        retry
      end
    
  • write_nonblock 非阻塞式写操作

    write_nonblock的行为和系统调用write(2)一模一样。它尽可能多地写入数据并返回写入的数量。

    和Ruby的write方法不同的是,后者可能会多次调用write(2)写入所有请求的数据。(write会引发阻塞是吗?)

    系统调用write(2)会可能引发阻塞,如果底层的write(2)仍处于阻塞,那你会得到一个Errno::EAGAIN异常

      begin
        loop do
          bytes = client.write_nonblock(payload)
          break if bytes >= payload.size
          payload.slice!(0, bytes)
          IO.select(nil, [client])  #没写完就等待可写,然后再写
        end
      rescue Errno::EAGAIN
        IO.select(nil, [client])    #对系统write引发的异常,等待可写,然后再写
        retry
      end
    

    write(2) 可能阻塞的原因: TCP的拥塞控制, 接收端未确认或者接收端无能力处理更多数据

  • accept_nonblock 非拥塞式接收

    accept 当侦听队列为空会阻塞,accept_nonblock 会抛出异常Errno::EAGAIN

      loop do
        begin
          connection = server.accept_nonblock
        rescue Errno::EAGAIN
          # 执行其他重要的工作。
          retry
        end
      end
    
  • connect_nonblock 非拥塞式连接

    如果connect_nonblock不能立即发起到远程主机的连接,它会在后台继续执行操作并产生Errno::EINPROGRESS


第12章 连接复用

  • loop+read_nonblock 多个socket造成大量系统调用, 浪费大量处理周期, 使用select可以避免

  • IO.select(for_reading, for_writing, for_writing)

    三个参数分别是希望从中进行读取的IO对象数组,希望进行写入的IO对象数组,在异常条件下使用的IO对象数组

    它返回一个数组的数组。IO.select返回一个包含了3个元素的嵌套数组,分别对应它的参数列表

    它会阻塞。IO.select是一个同步方法调用, 直到传入的某个IO对象状态发生变化

    IO.select还有第四个参数:一个以秒为单位的超时值。它可以避免IO.select永久地阻塞下, 如果在IO状态发生变化之前就已经超时,那么IO.select会返回nil

      connections = [<TCPSocket>, <TCPSocket>, <TCPSocket>]
        loop do
          # 查询select(2)哪一个连接可以进行读取了。
          ready = IO.select(connections)
          # 从可用连接中进行读取。
          readable_connections = ready[0]
          readable_connections.each do |conn|
            data = conn.readpartial(4096)
            process(data)
        end
      end
    
  • IO.select监视套接字读/写之外的事件

    1. EOF 监视的可读Socket接收到EOD,该套接字将作为数组一部分返回,在对其进行读取时,取决于当时所使用的read(2)的版本,可能会得到一个EOFError或nil。

    2. accept

    3. connect

         begin
           #发起到google.com端口80的非阻塞式连接。
           socket.connect_nonblock(remote_addr)
         rescue Errno::EINPROGRESS
           IO.select(nil, [socket]) #阻塞等待连接成功,socket可写
           begin
             socket.connect_nonblock(remote_addr) #再次尝试连接
           rescue Errno::EISCONN #连接已经成功的链接,抛出异常,表明成功
             # 成功!
           rescue Errno::ECONNREFUSED
             # 被远程主机拒绝。
           end
         end
      

      感觉下面的端口监控代码有问题

  • 高性能复用

    IO.select 监视的连接数越多,性能就越差,而且select(2)系统调用受到FD_SETSIZE的限制, 多数系统是1024

    poll(2)系统调用与select(2)略有不同,不过这点不同也仅限于表面而已

    Linux的epoll(2)以及BSD的kqueue(2)系统调用比select(2)和poll(2)效果更好、功能更先进。

    IO.select来自Ruby的核心代码库。它是在Ruby中进行复用的唯一手段, 某些gem包可以使用系统中性能最好的可用方法


第13章 Nagle算法

如果你使用的是HTTP协议,它的请求/响应至少够组成一个TCP分组, 因此Nagle算法除了会延缓最后一个分组发送之外,一般不会造成什么影响

每个Ruby Web服务器都禁用了该选项:

server.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)


第14章 消息划分

多条消息重用连接的想法同我们所熟悉的HTTP keep-alive特性背后的理念是一样的

消息划分常见的方式:

  1. 使用新行

  2. 使用内容长度

    消息发送方先计算出消息的长度,使用pack将其转换成固定宽度的整数,后面跟上消息主体一并发送


第15章 超 时

  • ruby timeout 库提供的超时机制, 是采用多线程, timeout计时器是一个新线程, 代码块中的代码放到当前线程中执行

    外记:

    Timeout::timeout: Note that this is both a method of module Timeout, so you can include Timeout into your classes so they have a timeout method, as well as a module method, so you can call it directly as ::timeout

  • 通过IO.select 超时,可以处理读取超时,接受超时,连接超时


第16章 DNS查询

MRI的GIL,可以理解阻塞式IO,但是不能理解C语言扩展的DNS查询,所以如果DNS查询长时间阻塞,MRI就不会释放GIL

resolv解决了这个问题:

    require 'resolv' # 库
    require 'resolv-replace' # 猴子修补

第17章 SSL套接字

TODO

第18章 紧急数据

  • TCP紧急数据,更多的时候被称作“带外数据”(out-of-band data),

    持将数据推到队列的前端,绕过其他已在传送途中的数据,以便于连接的另一端尽快接收到这些数据。

  • Socket#send(data, flag)

    不传flag,行为和write一致,flag为Socket::MSG_OOB表示紧急数据

  • connection.recv

    服务器必须明确接受紧急数据,否则服务器不会注意到紧急数据

      Socket.tcp_server_loop(4481) do |connection|
        # 优先接收紧急数据。
        urgent_data = connection.recv(1, Socket::MSG_OOB)
        data = connection.readpartial(1024)
      end
    

    如果不存在未处理的紧急数据, 调用connection.recv(1, Socket::MSG_OOB)则会失败,并产生Errno::EINVAL

    TCP实现对于紧急数据仅提供了有限的支持,一次只能发送一个字节的紧急数据。如果你要发送多个字节,那么只有最后一个字节会被视为紧急数据。之前的那些数据会视为普通的TCP数据流。

  • IO.select 的第三个参数就是监控带外数据的socket数组

  • 对接收方可设置将带外数据放入普通数据中

    connection.setsockopt :SOCKET, :OOBINLINE, true


第19章 网络架构模式


第20章 串行化

….


错误码汇总

  • Errno::EADDRINUSE 绑定到已经占用的端口, 或者绑定到处于TIME_WAIT状态且没有开启SO_REUSE_ADDR的套接字
  • Errno::EADDRNOTAVAIL 绑定到未知接口, 如server绑定到的ip地址不是本机ip
  • Errno::ECONNREFUSED 侦听队列已满
  • Errno::ETIMEOUT client在connect服务器阶段超时, 可能是server不存在或者网络延迟大
  • EOFError 当接收到EOF时, read仅仅是返回,而readpartial则会产生一个EOFError异常
  • Errno::EAGAIN 文件被标记用于非阻塞式IO,无数据可读, read_nonblock调用仍然会立即返回。 事实上,它产生了一个Errno::EAGAIN异常
  • Errno::EINVAL connection.recv(1, Socket::MSG_OOB)没有紧急数据可读取

    其他可能的还有write_nonblock accept_nonblock


ps. 今天终于把大神Jesse Storimer的三本书看完了,三本书都非常的短小精悍,举一反三。虽然有的章节的理解还是比较粗略,但是这三本书的确是帮助我对进程,线程和Socket的认识有了很大的提高。感觉非常幸运!书中的示例都是ruby写的,推荐给所有ruby程序员,但不局限于此。