socket 和 网络I/O模型

《UNIX 网络编程卷一:套接字联网API》笔记

套接字

套接字编程接口,是在 TCP/IP 协议族中,应用层进入传输层的接口。用套接字编写使用 TCP或UDP 的网络应用程序。应用层是用户进程,下面是系统内核的一部分功能。

原始套接字,raw socket,应用不使用传输层协议,直接用IP协议,例如:OSPF

协议族与I/O

套接字(socket): 一个标识端点的 IP、端口 组合。{ip : port}

套接字对(socket pair): 通过两端端点的四元组,唯一地标识一个连接。{本地ip : 本地port,对端ip : 对端port}

监听套接字(listening socket),{* : *},等待连接请求,被动打开。服务器的监听套接字对用{x.x.x.x : port_num,* : *}表示,匹配所有源/对端ip和源/对端端口

已连接套接字(connected socket),accept 返回的,三次握手完毕的套接字,已连接套接字对用{x.x.x.x : port_num,ip : port}表示,已连接套接字使用与监听套接字相同的本地IP和本地端口(单宿主服务器)。

TCP的几个状态

TCP状态转换图

套接字的几个函数对应的TCP操作

以单进程阻塞I/O,也就是 迭代服务器(同一时间只能处理一个客户请求) 为例

套接字函数
TCP连接的分组交换

socket

创建套接字,指定要使用的地址簇(ip)和套接字类型/传输层协议(tcp),返回一个套接字描述符,简称sockfd,用于在函数调用(connect、read 等)中标识这个套接字。

bind

将一个 本地IP:本地端口 赋值给一个套接字,限定该套接字只接收目的地为指定的本地IP、端口的客户连接。进程指定。

linsen

仅由 TCP server 调用。当 socket 函数创建了套接字,会假设为主动套接字,也就是将调用 connect 主动发起连接的套接字。linsen 将一个未连接的套接字转换成被动套接字,通知内核应该接受指向该套接字的连接请求(被动打开)。此时套接字的状态从 CLOSED 转到 LISTEN。

内核为每个监听套接字维护两个队列:

  • 未完成连接队列,收到了客户的 SYN,正在等待 TCP 三次握手完成。此时套接字的状态为 SYN_RCVD。
  • 已完成连接队列,已经完成 TCP 三次握手的客户,这些套接字处于 ESTABLISHED 状态。

当收到客户的SYN,TCP在未完成连接队列中创建一项新条目,同时继承监听套接字的参数,然后返回SYS、ACK,这一项一直保留在未完成连接队列中,直到收到 ACK,或者该项超时。如果三次握手正常完成,该项从未完成连接队列移到已完成连接队列的队尾。

指定 backlog,即两个队列长度之和。

TCP为监听套接字维护的两个队列

accept

仅由 TCP server 调用,从已完成连接的队头获得一个已完成连接,如果已完成连接的队列为空,进程被挂起进入睡眠状态(假定套接字为默认的阻塞方式),直到队列中有条目。

接收监听套接字描述符,返回 由内核自动为获得的已完成连接生成的已连接套接字描述符,以及对应的已连接套接字

connect

仅由 TCP client 调用,接收 socket 返回的套接字描述符,主动向 TCP server 发送建立连接的请求(主动打开),触发三次握手。调用 connect 之前不一定要调用 bind 绑定本地ip 和 本地端口,这样,内核会通过数据出口确定源ip,并选定一个临时端口作为源端口。

fork、exec

调用一次,返回两次

  • 在父进程中返回一次,返回的是新的子进程ID号(pid),用于记录并跟踪所有子进程
  • 在子进程中返回一次,始终为0,因为每个子进程都只有一个父进程,而且可以通过getppid获取父ID(ppid)

父进程调用fork前打开的所有描述符,在fork返回后,与子进程共享(复制程序代码与描述符)

每个 文件/套接字 都有一个引用计数器,在文件表项中维护,表示当前打开的 引用了该文件/套接字的描述符 个数。close 只是将计数值减一,真正清理套接字、释放资源,需要计数值为0。

fork 的两种典型用法:

  • 一个进程想要执行另一个程序

fork,新创一个进程,在内存中运行相同的程序代码
exec,换成另一个程序代码

fork、exec

比如,shell 执行可执行程序文件

  • 一个进程创建自身的副本,各自同时处理各自的操作

网络服务器

fork 后判断

fork 的限制在于,操作系统对于运行服务器的用户ID,能够同时拥有的子进程数的限制。

close

当套接字描述符的引用为0,发送 FIN 关闭套接字,终止 TCP 连接。

默认行为是将该套接字标记为已关闭,然后立即返回。这个套接字的描述符不能再被 read 或 write 使用。

shutdown

不管引用计数器,直接发送 FIN,可以半关闭连接。

并发服务器

前面提到的都是 迭代服务器,一次只能处理一个客户请求,如果希望同时服务多个客户,可以通过为每个客户 fork 一个子进程进行服务来实现。

当一个连接建立时,accept 返回 已连接套接字描述符(connfd),服务器接着调用 fork 创建子进程,监听套接字描述符和新的已连接套接字描述符在父进程和新的子进程之间共享/被复制,由子进程通过 connfd 处理客户请求,然后父进程关闭已连接套接字描述符,子进程关闭监听套接字描述符。最终,子进程只处理一个已连接套接字描述符,父进程在监听套接字上调用 accept 接受下一个客户连接。

任何进程在任何时候可以拥有的打开的描述符数量是有限的。

exit

EOF 字符,当使用control + C终止连接时发送。通过调用 exit 正常终止连接。

  • 客户端调用

由内核关闭所有打开的描述符,发送 FIN 给服务器(TCP 状态 FIN-WAIT-1),并在收到服务器的 FIN 后,回复 ACK (TCP 状态 FIN-WAIT-2)

  • 服务器子进程调用

由子进程关闭 已连接套接字描述符,发送 FIN 给客户端,收到 ACK

在服务器子进程终止时,会给父进程发送 SIGCHLD 信号,默认的处理行为是忽略,父进程不做处理,这样会导致子进程进入僵死状态(z)

信号(signal)

通知某个进程发生了某个事件,也称为软中断(software interrupt),信号通常是异步发生的,即进程预先不知道信号什么时候会发生

信号可以:

  • 由一个进程发送给另一个进程(或自身)
  • 由内核发给某个进程

SIGCHLD 是内核在任一进程终止时,发给父进程的信号

每个信号都有一个关联的处置/行为,可以设置信号的处理函数,捕获信号并处理

处理 SIGCHLD 信号

设置僵死(zombie)状态的目的,是维护子进程的信息,以便父进程在以后某个时候获取。如果一个进程终止,而该进程有子进程处于僵死状态,父进程ID重置为1(init进程),继承这些子进程的init进程将清理它们(wait 去除僵死状态)

留存僵死进程会占用内核空间,可能导致进程资源耗尽。

建立一个信号处理函数并在其中调用 wait 不足以防止出现僵死进程,因为:

几个连接同时终止,会同时发送多个 FIN,服务器几个子进程同时终止,同时有几个 SIGCHLD 信号传给父进程,信号不会排队等待依次处理,信号处理函数只执行一次,其他的变为僵死进程,这时需要循环调用 waitpid,指定进程。

需要在一个循环中调用 waitpid,以获取所有已终止子进程的状态。

# -*- coding: utf-8 -*-
import socket

#创建socket对象
s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)

#连接服务器,指定地址和端口号
s.connect(('127.0.0.1',10000))

#打印收到的消息
print s.recv(1024)

for data in ['xu','zhang','ting']:
    #发送数据
    s.send(data)
    #打印收到的数据
    print s.recv(1024)

#发送消息以关闭连接
s.send('exit')

s.close()

I/O 复用

在单进程中,当同时处理多个 I/O 时(比如 套接字 I/O 和 终端 I/O),进程会处理一个 I/O,阻塞于另一个 I/O,这需要进程预先通知内核有哪些 I/O,让内核一旦发现其中有 I/O 就绪,就通知进程。这就是I/O 复用,可以由 select 或 poll 函数实现。

调用 select,允许进程通知内核,等待多个事件中的任一个发生,并只在有一个或多个事件发生,或经历一段指定时间后再唤醒进程。

三种情况发生才会返回:

  • 一个集合中的任何描述符准备好被读取
  • 一个集合中的任何描述符准备好被写入
  • 一个集合中的任何描述符有异常需要处理

即,用 select 告诉内核,对那些描述符的 读取、写入、异常 感兴趣,不限于套接字描述符

有同时等待的最大描述符数限制。同时,由于对所有描述符公平扫描,一些长时间静止的描述符与常用描述符地位相同,浪费了资源。

只会将已连接描述符放在描述符集中。

套接字选项

getsockopt、setsockopt 函数

可以获取、设置影响套接字的选项,需要:

  • 一个打开的套接字描述符

  • 设置 level,指明设置的是哪一个级别的选项,通用的套接字选项、特定协议的可选项(IP、IPv6、IPv4 or IPv6、ICMPv6、TCP)

  • optname,选项名

获取、设置选项的时机
部分TCP已连接套接字从监听套接字继承选项

SO_REUSEADDR,允许重用本地地址

fcntl 函数

(file control,文件控制),对描述符进行控制操作,比如,设置描述符为 非阻塞I/O 或 信号驱动。

用 F_SETFL 设置 O_NONBLOCK 文件状态标识位,非阻塞式 I/O
用 F_SETFL 设置 O_ASYNC 文件状态标识位,信号驱动式 I/O

UNIX 下的五种 I/O 模型

  • 阻塞式 I/O
  • 非阻塞式 I/O
  • I/O 复用(select 和 poll)
  • 信号驱动式 I/O
  • 异步 I/O(POSIX)

一个输入操作的两个阶段

  1. 等待数据准备好。对于套接字,是等待从网络收到数据,并且在数据到达后,复制数据到内核缓冲区
  2. 从内核缓冲区复制数据到进程缓冲区,以便进程处理

阻塞式 I/O

默认情况下,所有套接字都是阻塞的。

发起一个不能立即完成的套接字调用,进程阻塞,等待调用结果。

可能阻塞的套接字调用/函数:

  1. 输入操作。当进程对一个会阻塞的套接字调用 read、recvfrom 等函数时,如果接受缓冲区没有数据可读,进程会进入睡眠状态,直到数据到达。
  2. 输出操作。当进程调用 write、sendto 等函数时,内核将从应用程序的缓冲区复制数据到套接字的发送缓冲区,如果发送缓冲区没有空间,进程会进入睡眠,直到有空间。
  3. 接受连接。当进程调用 accept 函数,但没有新的已完成连接,进程会进入睡眠状态。
  4. 发起连接。当进程调用 connect 函数,发送 SYN,等待服务器的 ACK 时。
阻塞式I/O

非阻塞式 I/O

将套接字设置为非阻塞,是告诉内核,当进程请求的 I/O 操作结果需要等待时,不阻塞进程,而是立即返回一个错误,以便进程可以做其他事情,但需要不时回来查看一下结果,即轮询(polling),这样做会耗费大量CPU时间。

非阻塞式I/O

I/O 复用(select 和 poll)

调用 select 或 poll,进程阻塞于这两个调用,而不是真正的 I/O 上。

阻塞于 select 后,就等待套接字变为可读,表示有数据已经准备好,可以读取。当 select 返回套接字可读的消息,会从内核复制数据到进程。

I/O 复用的优点是,可以同时等待多个描述符就绪。

但有同时等待的最大描述符数限制。同时,由于对所有描述符公平扫描,一些长时间静止的描述符与常用描述符地位相同,浪费了资源。

I/O 复用、多线程 使用的是阻塞式 I/O。但前者由单进程处理多个客户,后者为每个客户派生一个子进程进行处理。

I/O复用

chat server.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import socket,select

def broadcast_data(sock,message):
    for socket in CONNECTION_LIST:
        if socket != server_socket and socket != sock:
            try:
                socket.send(message)
            except:
                socket.close()
                CONNECTION_LIST.remove(socket)

if __name__ == '__main__':
    CONNECTION_LIST = []
    RECE_BUFFER = 4096
    PORT = 5000
    server_socket = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
    #print server_socket
    #<socket._socketobject object at 0x1004da050>
    
    server_socket.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)

    server_socket.bind(('0.0.0.0',PORT))
    server_socket.listen(10)
    CONNECTION_LIST.append(server_socket)
    print 'Chat server started on port ' + str(PORT)

    while 1:
        read_sockets,write_sockets,error_sockets = select.select(CONNECTION_LIST,[],[])
        # print read_sockets
        #[<socket._socketobject object at 0x1004da050>]
        
        for sock in read_sockets:
            if sock == server_socket:
                print sock
                #<socket._socketobject object at 0x1004da050>
                sockfd,addr = server_socket.accept()
                #print sockfd,addr
                #<socket._socketobject object at 0x1004e68a0> ('127.0.0.1', 61045)
                
                CONNECTION_LIST.append(sockfd)
                #print CONNECTION_LIST
                #[<socket._socketobject object at 0x1004da050>, <socket._socketobject object at 0x1004e68a0>]
                #[<socket._socketobject object at 0x1004da050>, <socket._socketobject object at 0x1004e68a0>, <socket._socketobject object at 0x1004fd130>]
                
                print 'Client (%s,%s) connected' % addr
                broadcast_data(sockfd,'[%s:%s] entered room\n' % addr)
            else:
                try:
                    data = sock.recv(RECE_BUFFER)
                    print data
                    #hi
                    if data:
                        broadcast_data(sock,'\r' + '<' + str(sock.getpeername()) + '>' + data)
                except:

                    addr = sock.getpeername()

                    broadcast_data(sock,'Client (%s,%s) is offline' % addr)
                    print 'Client (%s,%s) is offline' % addr
                    sock.close()
                    CONNECTION_LIST.remove(sock)
                    continue
    server_socket.close()

chat client.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import socket,select,string,sys

def prompt():
    sys.stdout.write('<You> ')
    sys.stdout.flush()

if __name__ == '__main__':
    if len(sys.argv) < 3 :
        print 'Usage : python telnet.py hostname port'
        sys.exit()
    host = sys.argv[1]
    port = int(sys.argv[2])
    
    s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
    s.settimeout(2)

    try:
        s.connect((host,port))
    except :
        print 'Unable to connect'
        sys.exit()
        
    print 'Connected to remote host. Start sending messages'
    prompt()
    while 1:
        rlist = [sys.stdin,s]
        read_list,write_list,error_list = select.select(rlist,[],[])
        for sock in read_list:
            if sock == s:
                data = sock.recv(4096)
                if not data :
                    print 'Disconnected from chat server'
                    sys.exit()
                else:
                    sys.stdout.write(data)
                    prompt()
            else:
                msg = sys.stdin.readline(
                s.send(msg)
                prompt()

信号驱动式 I/O

需要先开启套接字的信号驱动式 I/O 功能,用 sigaction 安装一个信号处理函数。由于调用后会立即返回,因此进程不会被阻塞,而是继续处理其他事情,一旦数据准备好了,内核会向进程返回 SIGIO 信号,通知已经准备好被读取,可以开始一个 I/O 操作,或者,可以让进程直接处理数据。

优势是,等待数据准备好的阶段不会阻塞。

信号驱动式I/O

异步 I/O

(由 POSIX 定义)

会通知内核进行某个 I/O 操作,并让内核在整个 I/O 操作完成后 回复消息 给进程。不需要像 select 或 poll 主动询问,也没有了询问描述符的数量限制

与信号驱动式的不同在于,前者通知 I/O 操作已经完成,而后者通知可以启动一个 I/O 操作。

要使用 异步I/O,需要给内核传递描述符、完成后如何通知进程 等。

调用后,会立即返回,在整个输入操作的 等待 和 复制 期间,进程都不会阻塞。

同步 I/O 与异步 I/O的区别是,真正的 I/O 操作(从内核复制到进程)阶段,同步 I/O 也不会阻塞进程。

异步I/O

五种I/O 模型比较

五种I/O模型比较

守护进程

inetd,超级服务器

线程

通常,当一个进程需要另一个实体完成某事,就 fork 一个子进程去处理,但:

  • fork 代价昂贵。需要将父进程的内存代码、所有描述符等复制到子进程,虽然有 写时复制(copy-on-write) 确保只在需要时操作,但代价依然很大。
  • fork 后,父子进程之间信息的传递需要 进程间通信(IPC)机制。fork时,父进程可以很容易向子进程传递消息,因为子进程是父进程的一个拷贝,但从子进程返回信息给父进程却很费力。

所以,出现了一个解决方法:多线程。

线程,是轻量级的进程(lightweight process),创建速度比进程快很多。每个进程中至少有一个线程。

同一个进程中的所有线程共享相同的全局内存,使得线程之间易于共享信息,但因此会有同步问题(一个线程对信息的修改,需要同步给其他线程。类似协同文档编辑)。共享的有:进程执行的代码、大多数数据、描述符、信号处理函数(是某种线程)、当前工作目录、用户ID、组ID 等。

create

当一个程序由exec启动执行时,会创建一个进程,同时在进程中自动创建称为初始线程/主线程的单个线程。其余线程通过create函数创建(create 作用类似 fork),每个线程由一个线程ID标识。创建时,需要指定由线程执行的函数、参数,线程通过调用函数执行,然后显示(用 exit)或隐式(函数返回)终止。

在线程之间不存在父子关系,所有线程(除了创建进程时自动被创建的初始线程)都在相同的层次级别上。线程并不维护已创建线程的列表,也不知道创建它的线程。

join

(类似 waitpid)允许线程等待一个指定线程的终止。阻塞正在调用的线程,直到指定的线程终止。

detach

一个线程可以分为两种:

  • 可汇合的/非脱离的(joinable,默认)

当线程终止时,线程ID 和 退出状态将留存,直到另一个线程对它调用 join。如果一个线程需要知道另一个线程什么时候终止,需要保持第二个线程的可汇合状态。

  • 脱离的(detached)

类似守护进程,当终止时,释放所有相关资源。

detach 函数将指定线程转为脱离状态。

close

主线程不关闭已连接套接字,因为同一进程中的所有线程共享全部描述符,一旦主线程关闭套接字,会终止相应连接。而且,创建新线程不会影响已打开描述符的引用计数。

tcp server.py

# -*- coding: utf-8 -*-
import socket,threading,time

#创建一个ipv4的TCP协议socket
s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)

#绑定要监听的网卡和端口号。
#可以是某一块网卡的IP地址,可以是‘0.0.0.0’表示的所有网络地址,也可以是‘127.0.0.1’表示的本地内部地址
#‘127.0.0.1’的客户端必须在同一台设备运行并建立连接。
s.bind(('127.0.0.1',10000))

#开始监听,用参数指定可以接入多少连接
s.listen(5)
print 'Waiting for connection...'

def tcplink(sock,addr):
    print 'Accept new connection from %s:%s..' % addr  #('127.0.0.1', 55608)
    sock.send('Welcome!') #sock的方法?
    while True:
        data = sock.recv(1024)
        time.sleep(1)
        if data == 'exit' or not data:
            break
        sock.send('hello,%s'% data)
    sock.close()
    print 'connection from %s:%s closed.'% addr #('127.0.0.1', 55608)

#通过一个永久循环接受来自客户端的连接请求
while True:
    #接受一个新连接
    sock,addr = s.accept()
    #print sock,addr
    #<socket._socketobject object at 0x1005fee50> ('127.0.0.1', 55608)
    
    #创建新线程处理TCP连接
    t = threading.Thread(target=tcplink,args=(sock,addr))
    t.start()

tcp client.py

# -*- coding: utf-8 -*-
import socket

#创建socket对象
s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)

#连接服务器,指定地址和端口号
s.connect(('127.0.0.1',10000))

#打印收到的消息
print s.recv(1024)

for data in ['xu','zhang','ting']:
    #发送数据
    s.send(data)
    #打印收到的数据
    print s.recv(1024)

#发送消息以关闭连接
s.send('exit')

s.close()

线程安全(thread-safe)

能被线程化的程序调用的函数必须是线程安全的,可以同时被两个及以上线程调用,并且能正确处理在各个线程中的变量,不会互相有影响。保证函数线程安全的一个方法是使用线程特定数据,对于使用静态变量的函数,被多个线程同时调用会有问题,静态变量无法为不同线程保存各自的值。

同步

为了有效地相互作用,线程必须同步其活动。这包括:

  • 通过修改共享数据进行隐式通信
  • 通过相互通知所发生的事件进行显式通信

线程库提供了以下同步机制:

  • 互斥锁
  • 条件变量
  • join

条件变量 和 互斥锁

共享的同步问题,解决方法:

条件变量(信号机制) + 互斥锁(互斥机制)

条件变量必须始终与互斥锁一起使用。给定的条件变量只能与一个互斥锁关联,但是互斥锁可用于多个条件变量。

  • 互斥锁

多个线程同时访问某个共享变量/全局变量,会有同步问题,线程编程,也称为 并发编程/并行编程,因为多个线程可以并发地(或平行地)运行且访问相同的变量。

多个线程更改同一个共享变量的问题,可以用互斥锁对这个变量进行保护。如果想访问这个变量,需要先获得这个变量的互斥锁(上锁),当一个线程对变量使用完毕,会释放这个互斥锁。如果试图上锁一个已经被其他某个线程锁住的变量,该线程会被阻塞,直到该变量被解锁。

  • 条件变量

当某个线程视图使用的一个共享变量被其他线程锁住,通常,会进入睡眠,在主循环轮询检查,直到某个线程通知它有事做,才会醒来。但这样轮询很浪费 CPU 资源。

作为主循环的替代,出现了条件变量,条件变量允许线程一直等待,直到一些事件或者条件发生。每个条件变量都要关联一个互斥锁,因为条件通常是线程共享的某个变量的值。允许不同线程设置、测试该变量,这要求有一个与该变量关联的互斥锁。

九种 TCP 服务器的设计

迭代服务器

单进程、单线程,一次只能处理一个请求

并发服务器

每个客户一个进程/线程

预先派生子进程,accept 无上锁保护

  1. 并非收到请求后才派生
  2. 多个进程对同一个监听描述符调用 accept 返回已连接套接字,对请求进行处理,最终关闭
  3. 所有子进程调用 accept 后睡眠,当一个客户连接到达,所有子进程都被唤醒(惊群),但只有最先运行的进程获得连接,其他进程继续睡眠
  4. 会有 select 冲突,除非进程阻塞于 accept 而不是 select

预先派生子进程,accept 使用文件锁保护

  1. 任一时刻只有一个子进程阻塞在 accept 调用中,其他阻塞于获取 保护 accept 的锁
  2. 文件锁,涉及文件系统操作,会比较耗时
  3. 有些系统不允许多个进程对 引用同一个监听套接字的描述符 调用accept

预先派生子进程,accept 使用线程锁保护

对于上面的第三点,可以改用线程锁保护 accept。互斥锁变量定义在进程共享的内存区,同时需要告知进程 这个是共享的互斥锁

预先派生子进程,传递描述符

只由父线程调用 accept,将已连接套接字描述符 传递 给某个子进程。不需要对 accept 使用线程锁保护。
但需要先创建 字节流管道,再 fork ,父进程关闭已连接套接字描述符,子进程关闭监听套接字描述符,然后父进程通过 select 跟踪子进程的状态,选择空闲子进程传递描述符。

并发服务器,每个客户一个线程

预先创建线程,每个线程各自 accept

使用互斥锁,同一时刻只有一个线程调用 accept

预先创建线程,主线程统一 accept

  1. 只由主线程调用 accept,并将客户连接传递给某个空闲线程
  2. 互斥锁 + 条件变量

同步 异步 与 阻塞 非阻塞

同步、异步,是指通信机制,通过什么方式回答结果

  • 同步,调用后,如果没有结果,调用不返回任何消息
  • 异步,调用后,直接返回,但并不是返回结果,被调用者,通过状态通知来通知调用者

阻塞、非阻塞,是指程序等待调用结果时的状态

  • 阻塞,在调用结果返回前,当前进程/线程被阻塞,直到被调用者返回结果才继续
  • 非阻塞,即使不能立即得到结果,也不会阻塞当前进程/线程,可以偶尔回来查看有无结果

参考资料

推荐阅读更多精彩内容