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
每一抽象层建立在低一层提供的服务上,并且为高一层提供服务,类似下图所示:
另外,下面是ISO定义的七层模型理论:
Socket的定义
首先需要明确Socket并不是某一层的协议,相反,它是应用层和运输层之间数据交互的一座抽象桥梁,即Socket是一组抽象的编程接口,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让socket去组织数据,以符合指定的协议。也就是说,Socket是封装了复杂的TCP/IP协议的一组用于交互应用层和运输层数据的接口。Socket的最初起源于UNIX,在Unix“一切皆文件”的思想下,Socket是一种”打开->读/写->关闭”模式的实现,只不过这种模式由本地端口之间变成了网络上不同主机的端口之间。服务器和客户端各自维护一个”文件”,在建立连接打开后,可以向自己文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
Socket通信流程
Socket通信一般分为有连接通信(TCP)和无连接通信(UDP),下面以TCP连接的Socket通信为例:
客户端流程
- 创建套接字(socket 函数)
- 向服务器发出连接请求(connect 函数)
- 和服务器端进行通信(send/recv 函数)
-
服务器端流程
创建套接字(socket 函数)
- 将套接字绑定到一个本地地址和端口上(bind 函数)
- 将套接字设为监听模式,准备接收客户端请求(listen 函数)
- 等待客户请求到来;当请求到来后,接受连接请求,返回一个新的对应于此次连接的套接字(accept 函数)
- 用返回的套接字和客户端进行通信(send/recv 函数)
- 返回,等待另一个客户请求
- 关闭套接字(close 函数)
注意: 上面的send类比与write,recv类比read
Java Socket示例
客户端代码:
public class Client {/*** 客户端向服务端发送消息*/public String sendMessage(String msg) {// 响应结果StringBuilder result = new StringBuilder();BufferedReader br = null;InputStream is = null;OutputStream os = null;PrintWriter pw = null;Socket socket = null;try {String host = "localhost";int port = 8877;// 1.和服务器【1270.0.01+8877】创建连接socket = new Socket(host, port);System.out.println("和服务器已建立连接....");// 要发送给服务器的信息os = socket.getOutputStream();pw = new PrintWriter(os);// 给服务端发msgpw.write(msg);pw.flush();socket.shutdownOutput();// 从服务器接收的信息is = socket.getInputStream();br = new BufferedReader(new InputStreamReader(is));String info;while ((info = br.readLine()) != null) {result.append(info);}} catch (Exception e) {e.printStackTrace();} finally {// 关闭流和sockettry {if (br != null) {br.close();}if (is != null) {is.close();}if (os != null) {os.close();}if (pw != null) {pw.close();}if (socket != null) {socket.close();}} catch (IOException e) {e.printStackTrace();}}return result.toString();}}
服务端代码:
public class Server extends Thread {/*** 服务端对象*/private ServerSocket serverSocket;public Server() {try {int port = 8877;serverSocket = new ServerSocket(port);System.out.println("ServerSocket创建了....");} catch (Exception e) {System.out.println("ServerSocket创建出错....");}}@Overridepublic void run() {System.out.println("服务端启动了,等待客户端发送消息....");// 循环监听,直到线程中断为止while (!this.isInterrupted()) {try {// accept是阻塞方法,等待客户端发消息Socket socket = serverSocket.accept();if (socket != null && !socket.isClosed()) {// 处理socket消息handleSocket(socket);}} catch (IOException e) {e.printStackTrace();}}}/*** 处理服务端接收到的socket消息** @param socket socket套接字*/private void handleSocket(Socket socket) {InputStream is = null;InputStreamReader isr = null;BufferedReader br = null;OutputStream os = null;PrintWriter pw = null;StringBuilder result = new StringBuilder();try {is = socket.getInputStream();isr = new InputStreamReader(is);br = new BufferedReader(isr);String info;// 从流中读取客户端消息while ((info = br.readLine()) != null) {result.append(info);}// 输出消息System.out.println("[服务端接收到客户端发来的信息] -> " + result);socket.shutdownInput();// 给客户端响应消息os = socket.getOutputStream();pw = new PrintWriter(os);pw.write("[服务端响应客户端] -> 我也会【" + result + "】啊");pw.flush();} catch (Exception e) {e.printStackTrace();} finally {// 关闭资源try {if (pw != null) {pw.close();}if (os != null) {os.close();}if (br != null) {br.close();}if (isr != null) {isr.close();}if (is != null) {is.close();}if (socket != null) {socket.close();}} catch (IOException e) {e.printStackTrace();}}}/*** 关闭socket*/public void closeSocketServer() {try {if (serverSocket != null && !serverSocket.isClosed()) {serverSocket.close();}} catch (IOException e) {e.printStackTrace();}}}
测试代码:
public class SocketTest {public static void main(String[] args) {// 创建服务端线程并启动new Server().start();// 休眠3s等待服务端线程启动成功try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}while (true) {System.out.print("请向服务端发送消息:");Scanner in = new Scanner(System.in);// 创建客户端并发消息String response = new Client().sendMessage(in.next());System.out.println(response);}}}
Socket的主要接口
socket()函数
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传输协议
注意:
- 上面的type和protocol是不可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合;当protocol为0时,会自动选择type类型对应的默认协议。
- 当调用 socket 函数创建一个 socket 描述符时,返回的 socket 描述符存在于它的协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。
- TCP传输协议可以确保数据的完整性,而UDP无法确保,因此TCP相比UDP更加安全可靠,但是传输速度会低于UDP
bind()函数
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];
};
- addrlen:对应的是地址的长度。通常服务器在启动的时候都会绑定一个公开的地址(如:ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。<a name="C3FMw"></a>## listen()、connect()函数```cint listen(int sockfd, int backlog);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()函数
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()

每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。TCP协议独立于 write()/send() 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。
注意: read()/recv() 函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。
以read/write函数组为例:
ssize_t write(int fd, const void *buf, size_t nbytes);ssize_t read(int fd, void *buf, size_t nbytes);
close()函数
int close(int fd);
在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
注意: 这里关闭的socket是使用accept函数创建出来的TCP socket,而是不socket函数创建的监听socket。
TCP中的Socket
(1)URG:紧急指针(urgent pointer)有效。(2)ACK:确认序号有效。(3)PSH:接收方应该尽快将这个报文交给应用层。(4)RST:重置连接。(5)SYN:建立一个新连接。(6)FIN:断开一个连接。
TCP三次握手

[Shake 1] 套接字A:“你好,套接字B,我这里有数据要传送给你,建立连接吧。”[Shake 2] 套接字B:“好的,我这边已准备就绪。”[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四次挥手

[Shake 1] 套接字A:“任务处理完毕,我希望断开连接。”[Shake 2] 套接字B:“哦,是吗?请稍等,我准备一下。”等待片刻后……[Shake 3] 套接字B:“我准备好了,可以断开连接了。”[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状态。
