Socket通信模型

TCP/IP协议族

在网络中,位于两台不同之间的进程不能像本地一样使用Pid来标识一个进程,而TCP/IP协议族就解决了这个问题,网络层的“ip地址”可以唯一标识网络中的主机,而传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。这样利用三元组(ip地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互,也就是说,TCP/IP就是网络之间的进程通信使用的。TCP/IP(Transmission Control Protocol/Internet Protocol)即传输控制协议/网间协议,是一个工业标准的协议集,它是为广域网(WANs)设计的,它定义了主机如何连入因特网及数据如何在它们之间传输的标准。从字面意思来看TCP/IP是TCP和IP协议的合称,但实际上TCP/IP协议是指因特网整个TCP/IP协议族。不同于ISO模型的七个分层,TCP/IP协议参考模型把所有的TCP/IP系列协议归类到四个抽象层中:

  • 应用层:TFTP,HTTP,SNMP,FTP,SMTP,DNS,Telnet
  • 运输层:TCP,UDP
  • 网络层:IP,ICMP,IGMP
  • 数据链路层:SLIP,CSLIP,PPP,MTU

每一抽象层建立在低一层提供的服务上,并且为高一层提供服务,类似下图所示:
image.png
另外,下面是ISO定义的七层模型理论:
image.png

Socket的定义

首先需要明确Socket并不是某一层的协议,相反,它是应用层和运输层之间数据交互的一座抽象桥梁,即Socket是一组抽象的编程接口,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让socket去组织数据,以符合指定的协议。也就是说,Socket是封装了复杂的TCP/IP协议的一组用于交互应用层和运输层数据的接口。Socket的最初起源于UNIX,在Unix“一切皆文件”的思想下,Socket是一种”打开->读/写->关闭”模式的实现,只不过这种模式由本地端口之间变成了网络上不同主机的端口之间。服务器和客户端各自维护一个”文件”,在建立连接打开后,可以向自己文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
image.png

Socket通信流程

Socket通信一般分为有连接通信(TCP)和无连接通信(UDP),下面以TCP连接的Socket通信为例:
Socket - 图5

客户端流程

  1. 创建套接字(socket 函数)
  2. 向服务器发出连接请求(connect 函数)
  3. 和服务器端进行通信(send/recv 函数)
  4. 关闭套接字(close 函数)

    服务器端流程

  5. 创建套接字(socket 函数)

  6. 将套接字绑定到一个本地地址和端口上(bind 函数)
  7. 将套接字设为监听模式,准备接收客户端请求(listen 函数)
  8. 等待客户请求到来;当请求到来后,接受连接请求,返回一个新的对应于此次连接的套接字(accept 函数)
  9. 用返回的套接字和客户端进行通信(send/recv 函数)
  10. 返回,等待另一个客户请求
  11. 关闭套接字(close 函数)

    注意: 上面的send类比与write,recv类比read

Java Socket示例

客户端代码:

  1. public class Client {
  2. /**
  3. * 客户端向服务端发送消息
  4. */
  5. public String sendMessage(String msg) {
  6. // 响应结果
  7. StringBuilder result = new StringBuilder();
  8. BufferedReader br = null;
  9. InputStream is = null;
  10. OutputStream os = null;
  11. PrintWriter pw = null;
  12. Socket socket = null;
  13. try {
  14. String host = "localhost";
  15. int port = 8877;
  16. // 1.和服务器【1270.0.01+8877】创建连接
  17. socket = new Socket(host, port);
  18. System.out.println("和服务器已建立连接....");
  19. // 要发送给服务器的信息
  20. os = socket.getOutputStream();
  21. pw = new PrintWriter(os);
  22. // 给服务端发msg
  23. pw.write(msg);
  24. pw.flush();
  25. socket.shutdownOutput();
  26. // 从服务器接收的信息
  27. is = socket.getInputStream();
  28. br = new BufferedReader(new InputStreamReader(is));
  29. String info;
  30. while ((info = br.readLine()) != null) {
  31. result.append(info);
  32. }
  33. } catch (Exception e) {
  34. e.printStackTrace();
  35. } finally {
  36. // 关闭流和socket
  37. try {
  38. if (br != null) {
  39. br.close();
  40. }
  41. if (is != null) {
  42. is.close();
  43. }
  44. if (os != null) {
  45. os.close();
  46. }
  47. if (pw != null) {
  48. pw.close();
  49. }
  50. if (socket != null) {
  51. socket.close();
  52. }
  53. } catch (IOException e) {
  54. e.printStackTrace();
  55. }
  56. }
  57. return result.toString();
  58. }
  59. }

服务端代码:

  1. public class Server extends Thread {
  2. /**
  3. * 服务端对象
  4. */
  5. private ServerSocket serverSocket;
  6. public Server() {
  7. try {
  8. int port = 8877;
  9. serverSocket = new ServerSocket(port);
  10. System.out.println("ServerSocket创建了....");
  11. } catch (Exception e) {
  12. System.out.println("ServerSocket创建出错....");
  13. }
  14. }
  15. @Override
  16. public void run() {
  17. System.out.println("服务端启动了,等待客户端发送消息....");
  18. // 循环监听,直到线程中断为止
  19. while (!this.isInterrupted()) {
  20. try {
  21. // accept是阻塞方法,等待客户端发消息
  22. Socket socket = serverSocket.accept();
  23. if (socket != null && !socket.isClosed()) {
  24. // 处理socket消息
  25. handleSocket(socket);
  26. }
  27. } catch (IOException e) {
  28. e.printStackTrace();
  29. }
  30. }
  31. }
  32. /**
  33. * 处理服务端接收到的socket消息
  34. *
  35. * @param socket socket套接字
  36. */
  37. private void handleSocket(Socket socket) {
  38. InputStream is = null;
  39. InputStreamReader isr = null;
  40. BufferedReader br = null;
  41. OutputStream os = null;
  42. PrintWriter pw = null;
  43. StringBuilder result = new StringBuilder();
  44. try {
  45. is = socket.getInputStream();
  46. isr = new InputStreamReader(is);
  47. br = new BufferedReader(isr);
  48. String info;
  49. // 从流中读取客户端消息
  50. while ((info = br.readLine()) != null) {
  51. result.append(info);
  52. }
  53. // 输出消息
  54. System.out.println("[服务端接收到客户端发来的信息] -> " + result);
  55. socket.shutdownInput();
  56. // 给客户端响应消息
  57. os = socket.getOutputStream();
  58. pw = new PrintWriter(os);
  59. pw.write("[服务端响应客户端] -> 我也会【" + result + "】啊");
  60. pw.flush();
  61. } catch (Exception e) {
  62. e.printStackTrace();
  63. } finally {
  64. // 关闭资源
  65. try {
  66. if (pw != null) {
  67. pw.close();
  68. }
  69. if (os != null) {
  70. os.close();
  71. }
  72. if (br != null) {
  73. br.close();
  74. }
  75. if (isr != null) {
  76. isr.close();
  77. }
  78. if (is != null) {
  79. is.close();
  80. }
  81. if (socket != null) {
  82. socket.close();
  83. }
  84. } catch (IOException e) {
  85. e.printStackTrace();
  86. }
  87. }
  88. }
  89. /**
  90. * 关闭socket
  91. */
  92. public void closeSocketServer() {
  93. try {
  94. if (serverSocket != null && !serverSocket.isClosed()) {
  95. serverSocket.close();
  96. }
  97. } catch (IOException e) {
  98. e.printStackTrace();
  99. }
  100. }
  101. }

测试代码:

  1. public class SocketTest {
  2. public static void main(String[] args) {
  3. // 创建服务端线程并启动
  4. new Server().start();
  5. // 休眠3s等待服务端线程启动成功
  6. try {
  7. Thread.sleep(3000);
  8. } catch (InterruptedException e) {
  9. e.printStackTrace();
  10. }
  11. while (true) {
  12. System.out.print("请向服务端发送消息:");
  13. Scanner in = new Scanner(System.in);
  14. // 创建客户端并发消息
  15. String response = new Client().sendMessage(in.next());
  16. System.out.println(response);
  17. }
  18. }
  19. }

image.png

Socket的主要接口

socket()函数

  1. int socket(int domain, int type, int protocol);

socket函数类比于普通文件的打开操作,普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:

  • domain:即协议域,又称为协议族(family)。常用的协议族: AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如:AF_INET决定了要用ipv4地址(32位)与端口号(16位)的组合、AF_UNIX决定了要用一个绝对路径名作为地址
  • type:指定socket类型。常用的socket类型:SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET
  • protocol:指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议

    注意:

    1. 上面的type和protocol是不可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合;当protocol为0时,会自动选择type类型对应的默认协议。
    2. 当调用 socket 函数创建一个 socket 描述符时,返回的 socket 描述符存在于它的协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。
    3. TCP传输协议可以确保数据的完整性,而UDP无法确保,因此TCP相比UDP更加安全可靠,但是传输速度会低于UDP

bind()函数

  1. int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

bind()函数是把一个地址族中的特定地址赋给socket描述符,例如:对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。函数的三个参数依次为:

  • sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket,bind()函数就是将给这个描述字绑定一个ip+端口。
  • addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址,这个地址结构根据地址创建socket时的地址协议族的不同而不同,如: ```c // ipv4对应: struct sockaddr_in { sa_family_t sin_family; in_port_t sin_port;
    struct in_addr sin_addr;
    };

struct in_addr { uint32_t s_addr;
};

// ipv6对应: struct sockaddr_in6 { sa_family_t sin6_family;
in_port_t sin6_port;
uint32_t sin6_flowinfo;
struct in6_addr sin6_addr;
uint32_t sin6_scope_id;
};

struct in6_addr { unsigned char s6_addr[16];
};

// Unix域对应:

define UNIX_PATH_MAX 108

struct sockaddr_un { sa_family_t sun_family;
char sun_path[UNIX_PATH_MAX];
};

  1. - addrlen:对应的是地址的长度。
  2. 通常服务器在启动的时候都会绑定一个公开的地址(如:ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。
  3. <a name="C3FMw"></a>
  4. ## listen()、connect()函数
  5. ```c
  6. int listen(int sockfd, int backlog);
  7. int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
listen函数的参数描述如下:

  • sockfd:服务端被监听的socket
  • backlog:服务端对应socket的请求队列的最大长度

    注意: socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。

connect函数的参数描述如下:

  • socketd:客户端的socket,由系统随机生成
  • addr:指向服务端的socket地址的结构体指针
  • addrlen:addr指向的地址的长度

    注意: 客户端通过调用connect函数来建立与TCP服务器的连接。

accept()函数

  1. int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类比于普通文件的读写I/O操作。accept函数的参数描述:

  • sockfd:被监听的服务端socket
  • addr:指向客户端的socket地址的结构体指针
  • addrlen:上面addr的长度

如果accpet函数成功,那么其返回值是由操作系统内核自动生成的一个全新的socket描述字,代表与返回客户的TCP连接。

注意: accept的第一个参数为服务器的被监听的socket描述字,它是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是完成TCP连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在,而内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。

网络 I/O 函数

在服务端与客户端建立连接之后,就可以调用网络I/O进行读写操作了,即进行网络上不同主机进程之间的通信。网络I/O分为以下几组:

  • recv()/send()
  • read()/write()
  • readv()/writev()
  • recvmsg()/sendmsg()
  • recvfrom()/sendto()

image.png
每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。TCP协议独立于 write()/send() 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。

注意: read()/recv() 函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。

以read/write函数组为例:

  1. ssize_t write(int fd, const void *buf, size_t nbytes);
  2. ssize_t read(int fd, void *buf, size_t nbytes);

close()函数

  1. int close(int fd);

在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。

注意: 这里关闭的socket是使用accept函数创建出来的TCP socket,而是不socket函数创建的监听socket。

TCP中的Socket

  1. 1URG:紧急指针(urgent pointer)有效。
  2. 2ACK:确认序号有效。
  3. 3PSH:接收方应该尽快将这个报文交给应用层。
  4. 4RST:重置连接。
  5. 5SYN:建立一个新连接。
  6. 6FIN:断开一个连接。

TCP三次握手

image.png

  1. [Shake 1] 套接字A:“你好,套接字B,我这里有数据要传送给你,建立连接吧。”
  2. [Shake 2] 套接字B:“好的,我这边已准备就绪。”
  3. [Shake 3] 套接字A:“谢谢你受理我的请求。

注意: 客户端调用 socket() 函数创建套接字后,因为没有建立连接,所以套接字处于CLOSED状态;服务器端调用 listen() 函数后,套接字进入LISTEN状态,开始监听客户端请求。

  • 当客户端调用 connect() 函数后,TCP协议会组建一个数据包,并设置 SYN 标志位,表示该数据包是用来建立同步连接的。同时生成一个随机数字 1000,填充“序号(Seq)”字段,表示该数据包的序号。完成这些工作,开始向服务器端发送数据包,客户端就进入了SYN-SEND状态。
  • 服务器端收到数据包,检测到已经设置了 SYN 标志位,就知道这是客户端发来的建立连接的“请求包”。服务器端也会组建一个数据包,并设置 SYN 和 ACK 标志位,SYN 表示该数据包用来建立连接,ACK 用来确认收到了刚才客户端发送的数据包。服务器生成一个随机数 2000,填充“序号(Seq)”字段。2000 和客户端数据包没有关系。服务器将客户端数据包序号(1000)加1,得到1001,并用这个数字填充“确认号(Ack)”字段。服务器将数据包发出,进入SYN-RECV状态。
  • 客户端收到数据包,检测到已经设置了 SYN 和 ACK 标志位,就知道这是服务器发来的“确认包”。客户端会检测“确认号(Ack)”字段,看它的值是否为 1000+1,如果是就说明连接建立成功。接下来,客户端会继续组建数据包,并设置 ACK 标志位,表示客户端正确接收了服务器发来的“确认包”。同时,将刚才服务器发来的数据包序号(2000)加1,得到 2001,并用这个数字来填充“确认号(Ack)”字段。客户端将数据包发出,进入ESTABLISED状态,表示连接已经成功建立。
  • 服务器端收到数据包,检测到已经设置了 ACK 标志位,就知道这是客户端发来的“确认包”。服务器会检测“确认号(Ack)”字段,看它的值是否为 2000+1,如果是就说明连接建立成功,服务器进入ESTABLISED状态。

    TCP四次挥手

    image.png

    1. [Shake 1] 套接字A:“任务处理完毕,我希望断开连接。”
    2. [Shake 2] 套接字B:“哦,是吗?请稍等,我准备一下。”
    3. 等待片刻后……
    4. [Shake 3] 套接字B:“我准备好了,可以断开连接了。”
    5. [Shake 4] 套接字A:“好的,谢谢合作。”

    建立连接后,客户端和服务器都处于ESTABLISED状态。在互相传输完数据之后,这时,客户端发起断开连接的请求:

  • 客户端调用 close() 函数后,向服务器发送 FIN 数据包,进入FIN_WAIT_1状态。FIN 是 Finish 的缩写,表示完成任务需要断开连接。

  • 服务器收到数据包后,检测到设置了 FIN 标志位,知道要断开连接,于是向客户端发送“确认包”,进入CLOSE_WAIT状态。服务器收到请求后并不是立即断开连接,而是先向客户端发送“确认包”,告诉它我知道了,我需要准备一下才能断开连接。
  • 客户端收到“确认包”后进入FIN_WAIT_2状态,等待服务器准备完毕后再次发送数据包。
  • 等待片刻后,服务器准备完毕,可以断开连接,于是再主动向客户端发送 FIN 包,告诉它我准备好了,断开连接吧。然后进入LAST_ACK状态。
  • 客户端收到服务器的 FIN 包后,再向服务器发送 ACK 包,告诉它你断开连接吧。然后进入TIME_WAIT状态。
  • 服务器收到客户端的 ACK 包后,就断开连接,关闭套接字,进入CLOSED状态。