TCP/IP协议及Python实现

OSI参考模型与TCP/IP协议

OSI参考模型是由ISO提出的作为通信协议设计的模型。该模型将通信过程分成7个部分,又称OSI七层模型,通过这七层模型,将通信过程中的不同功能进行划分,每层仅需要对其上一层提供特定的接口,以及接收下一层提供的特定接口,从而对复杂的通信过程进行简化,只需要关注某一层的实现以及保持其接口统一即可。这七层模型为:

  1. 物理层 : 该层决定数字电路中的0、1对应的电压状态,并且规定连接器和网线的规格,在物理层面上确保连接的畅通有效;
  2. 数据链路层 : 该层确保不同设备之间的数据传送以及识别,将0、1的比特流转换为真实的数据,并保证设备之间通过物理地址(MAC地址)互相识别;
  3. 网络层 : 该层决定每个设备的在网络中的地址,以及路由的规则;
  4. 传输层 : 该层确保数据在两个设备之间可靠传输,检查是否有数据丢失以及是否到达正确的目标;
  5. 会话层 : 该层负责建立通信后的管理,何时建立通信,何时断开通信,每次连接保持多长时间等等;
  6. 表示层 : 该层负责将真实的信息内容,如文字、图像、声音、影片等,转换为网络传输中的标准格式,确保网络中每台设备可以正确解读这些信息;
  7. 应用层 : 该层包含各种网络应用协议,如电子邮件协议、文件传输协议、超文本传输协议等等;

OSI参考模型仅仅是ISO为了不同设备之间建立相互通信的标准化建议,每一层模型都代表了通信过程中应该实现的功能。OSI协议则是满足OSI七层模型的协议,由于各种原因,虽然ISO做了很多标准化工作,OSI协议并没有得到流行,而真正流行的协议则是TCP/IP协议。

TCP/IP协议同样有一套参考模型,该模型和OSI参考模型不同,只有四层:

  1. 链路层 : 负责各种数据传输的硬件,如网线、网卡、交换机、中继、路由器等等网络硬件设备,包含OSI参考模型中的1、2两层,TCP/IP模型并没有对这一层做出详细的描述;
  2. 网络层 : 该层和OSI参考模型的网络层对应,包含网络地址协议: IP(Internet Protocol)协议,以及为了识别该地址的辅助协议,如:DNS、ARP、ICMP、DHCP等等;
  3. 传输层 : 该层和OSI参考模型的传输层对应,建立两个设置之间的连接,包含可靠传输协议: TCP(Transmission Control Protocol)协议,以及不可靠数据报协议: UDP(User Datagram Protocol)协议。识别IP协议提供的地址以及上一层应用提供的端口号;
  4. 应用层 : 该层包括OSI参考模型中的5、6、7层,包含具体的应用程序提供的协议,如: URL、HTTP、TLS/SSL、SSH、FTP、SMTP、POP、IMAP等等;

TCP/IP协议在上述四层模型的基础上提出具体协议内容,并且随着UNIX系统的普及得到广泛流行,和OSI模型关注每一层实现的功能不同,TCP/IP模型更加关注的是功能的实现。

在TCP/IP的制定过程中,就是先有了这一系列的协议,再通过RFC(Request For Comment)文档作为标准传播开来。在协议的制定过程中,往往先实现了某一功能,然后对其进行实验,所以有着开放性实用性两大特点。

建立连接

使用TCP协议作为连接协议,在建立连接的过程中,既要对下层协议传递的IP地址进行设备识别,也要通过上层协议传递的端口号识别具体的应用程序,通过IP地址和端口号,才可以在两个应用程序建立连接。而结合这两个地址,需要使用socket(套接字)的接口,应用程序通过该接口,设置目标IP地址和端口号,TCP协议获取该套接字,并且实现相应的连接过程。

在TCP连接的建立过程中,发起连接的设备成为客户端,响应连接的设备成为服务器。

首先需要一台服务器,服务器的功能是监听本设备的某个端口,当有客户端请求访问该端口时,做出响应的回应。

服务端程序:

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

import socket
from threading import Thread, activeCount


def tcplink(sock, add):
    print("Accept a connection from %s:%s" % add)
    try:
        sock.send("Hello, there! you are from %s:%s" % add)
        is_sweet = True
        while 1:
            data = sock.recv(1024)
            if not data or data == 'exit':
                if is_sweet:
                    sock.send("Bye, sweet!")
                else:
                    sock.send("funcking away!")
                break
            if data.find('fuck') >= 0:
                is_sweet = False
                sock.send("funcking back!")
            else:
                sock.send("You say %s, yeah?" % data)
    finally:
        sock.close()
    print("Connection from %s:%s is closed" % add)


if __name__ == '__main__':
    s = socket.socket()
    s.bind(('127.0.0.1', 9000))
    s.listen(5)
    while 1:
        sock, add = s.accept()
        t = Thread(target=tcplink, args=(sock, add))
        t.start()
        while 1:
            if activeCount() < 5:
                break

在上面的程序中,首先使用默认参数创建一个套接字,并使用该套接字监听本地设备的9000端口,客户端通过和该端口建立连接。

客户端程序:

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

import socket


def input(sock):
    accept = send(sock)
    accept.next()
    while 1:
        msg = raw_input()
        accept.send(msg)
        if msg == 'exit':
            break


def send(sock):
    while 1:
        msg = (yield)
        sock.send(msg)
        print sock.recv(1024)


if __name__ == '__main__':
    s = socket.socket()
    s.connect(('127.0.0.1', 9000))
    print s.recv(1024)
    try:
        input(s)
    finally:
        s.close()

该客户端通过连接服务器,就可以和自己聊聊天(这么认真肯定不是自动回复):

演示

实现细节

1. 端口

上述例子中的9000就是一个端口,每一台设备都会有很多端口,有些端口具有特殊的用途,如22端口用于ssh登陆协议,80端口用于http协议等等,不能使用已经被占用的端口,小于1024的端口是Internet标准端口,不能被随意使用。

IP地址和端口的元组可以确定应用程序在网络上的具体位置,127.0.0.1表示本地IP地址,而0.0.0.0则表示所有的IP地址。

2. 套接字

通过socket.socket()实例化一个套接字,默认使用IPv4协议,以及面向流的TCP协议,通过传入其他可用协议更改。

在服务器程序上,套接字实例化后完成以下任务:

  1. 使用bind()方法和本地(127.0.0.1)的9000绑定
  2. 使用listen()方法监听该端口,并设置最大等待连接数为5
  3. 使用accept()方法接收来自客户端的连接请求,并获取相应的socket以及地址信息
  4. 开启线程来获取客户端请求recv()方法(每次最多接收1024个字节),以及作出相应的响应send()方法
  5. 保持连接状态,直到客户端发送exit消息或者客户端关闭连接

在客户端程序上,套接字实例化后的任务为:

  1. 通过connect()方法连接服务器:127.0.0.1:9000
  2. 当控制台有输入内容且不是exit的时候,向服务器发送输入内容
  3. 使用recv()方法,接收服务器返回的消息,每次最多接收1024字节

有关socket的内容详见官方文档

3. TCP连接过程

TCP通过套接字进行连接,为了保证连接的可靠性,需要进行三次握手:

  1. 客户端发送一个建立连接的请求SYN(Synchronize)数据包,并处于SYN_SENT状态;
  2. 出于LISTEN状态的服务器端接受到SYN包,并且返回ACK(Acknowledge)确认应答以及SYN包请求建立连接,并出于SYN_RECV状态,等待客户端返回ACK
  3. 客户端收到服务器端返回的ACK以及SYN,收到ACK进入待命状态ESTABLISHED,对SYN返回ACK确认包;
  4. 服务器端收到ACK并且进入待命状态ESTABLISHED,三次握手成功;

这个过程非常有趣,博弈论里有一个著名的协同攻击难题,是格莱斯(J. Gray)于1978年提出的:

有两个将军占据两个山头等候敌人,将军A得到情报敌人刚刚到达,立足未稳,如果两军一起进攻,就能获得胜利,而如果只有一方进攻的话,就会失败。于是将军A遇到一个难题:如何与将军B协同进攻?当时没有电报,只能派出通信兵。将军A派出通信兵告诉将军B:黎明一起进攻!但是情报员在中途可能会失踪或者被抓捕,将军A并不能确定将军B是否收到了消息。然而事实上情报员回来了,但是将军A又陷入了困境:他不知道将军B知不知道情报员已经回来了。于是将军A又派情报员去将军B那里,但是这次还是不能确定情报员一定能够达。

格莱斯提出这个问题,并且被证明无论该情报员来回多少次都不能让将军一起进攻,原因是黎明前一起进攻这件事情无法形成公共知识。

那么在TCP连接的的第三步,客户端其实也无法确保服务端可以收到来自客户端的ACK确认包,这个包可能会产生丢包,服务器就无法从SYN_RECV状态进入到ESTABLISHED状态,当服务器等待获取的时间大于重发超时时间时,会尝试重新发送SYN以及ACK包,当时间超过SYN Timeout时间后,本次连接失败。

建立连接之后,为了保证数据可靠性,TCP协议还需要在传输的过程中进行一些工作来保证数据的可靠性:

  1. 客户端给服务器端发送数据时,会添加一个首部字段,该字段包含发送数据的编号以及数据的长度信息;
  2. 服务器端接收客户端发来的数据,检查首部字段是否已经被接收,以及数据长度是否一致,并且返回确认或者不确认的应答;
  3. 客户端等待接收服务器端的应答,如果收到不确认的应答或者超过重发超时的时间(1s的整数倍),则重新发送之前的数据;

在数据传输以及确认应答的过程中,完成数据的传输,由于上述三个步骤的保证,在传输过程中,即使出现丢包(发送时丢包或者确认应答丢包),都可以保证数据的准确性。

同样的,在完成连接传输之后,切断连接也需要进行握手,客户端发送FIN的切断连接请求,而客户端则需要分别发送两个包:ACKFIN(因为一旦切断确认就不能发送切断请求了,和连接时有所不同),然后客户端再次发送ACK确认切断连接。

也就是说,一次正常完整的TCP连接,除了发送的数据之外,需要额外的7个数据包才能完成。

更多协议细节可以参考《图解TCP/IP》中的内容。