在网络化的今天,Socket已成为构建分布式系统、实现进程间通信的利器。无论是搭建Web服务器、还是开发网络游戏,Socket编程技能都是必不可少的武器。本文将为你娓娓道来Socket编程的精髓,包括基本流程概览、常用函数剖析,以及精彩实例演示,助你一臂之力登上Socket编程高手之路。
一、套接字编程基本流程
套接字(Socket)是支持TCP/IP协议的网络通信机制,可用于在同一台主机的进程间通信或不同主机之间的进程间通信。在Linux下使用Socket进行编程通常包括以下几个步骤:
- 创建Socket
- 绑定 Socket(绑定地址信息)
- 监听连接请求(TCP服务器)
- 接受客户端链接
- 发送和接收数据
- 关闭Socket
其中第3步仅针对基于TCP的服务器程序,对于TCP客户端和UDP程序可以省略。
二、套接字函数剖析 -socket(): 创建套接字
socket()
函数是网络编程中用来创建端点(即套接字)的基础函数。在Unix和类Unix系统中,套接字用于实现进程间通信(IPC)和网络通信。
以下是关于 socket()
函数的一些详细说明:
1、函数原型:
int socket(int domain, int type, int protocol);
2、参数:
-
domain
指定通信协议的家族。常见的有:
-
AF_INET
:IPv4网络协议 -
AF_INET6
:IPv6网络协议 -
AF_UNIX
:Unix域套接字通常来说 AF_INET 使用更多。
-
-
type
指定套接字的类型。常见的有:
-
SOCK_STREAM
:提供面向连接、可靠的字节流服务(TCP) -
SOCK_DGRAM
:提供无连接的UDP服务 -
SOCK_SEQPACKET
:有序、可靠的数据包服务这里重点说一下
SOCK_SEQPACKET
类型:SOCK_SEQPACKET
是一种套接字类型,它提供了有序、可靠的数据包服务。这种类型的套接字介于传统的字节流套接字(SOCK_STREAM
)和数据报套接字(SOCK_DGRAM
)之间。以下是SOCK_SEQPACKET
的一些关键特性:-
有序性:与
SOCK_DGRAM
不同,SOCK_SEQPACKET
保证数据包的顺序。如果数据包丢失或顺序错误,它会重新发送或重新排序,以确保接收方收到的数据包是有序的。 -
可靠性:
SOCK_SEQPACKET
提供了与SOCK_STREAM
类似的可靠性保证。它确保数据包正确无误地到达目的地,如果传输过程中出现错误,会进行重试。 -
数据包服务:与
SOCK_STREAM
不同,SOCK_SEQPACKET
保留了数据包的边界。发送的数据被组织成独立的数据包,接收方接收到的也是独立的数据包,而不是一个连续的数据流。 -
使用场景:
SOCK_SEQPACKET
适用于需要可靠传输和数据包边界保留的应用,例如某些类型的文件传输、实时通信等场景。- 需要有序数据传输的应用:当应用程序需要确保数据按照发送的顺序被接收,并且每个数据包都是完整无损的,
SOCK_SEQPACKET
是一个很好的选择。 - 需要保留消息边界的应用:与
SOCK_STREAM
不同,SOCK_SEQPACKET
保留了数据包的边界,使得接收方可以接收到与发送方完全相同的数据块。 - 实时通信系统:在需要实时或近实时通信的应用中,例如某些类型的视频会议或在线游戏,
SOCK_SEQPACKET
可以确保数据包的顺序和完整性。 - 协议栈开发:在开发新的协议或协议栈时,如果需要一种既可靠又有序的数据传输方式,
SOCK_SEQPACKET
可以作为一个选项。 - 特定协议的实现:
SOCK_SEQPACKET
可以作为实现特定协议的传输层,例如 SCTP (Stream Control Transmission Protocol),它是一个支持多路复用、可靠传输的传输层协议。 - Unix 域套接字:在 Unix 和类 Unix 系统上,
SOCK_SEQPACKET
可用于 Unix 域套接字,提供有序、可靠的数据传输。 - 需要可靠传输但又希望避免字节流特性的应用:对于需要可靠传输但又希望避免
SOCK_STREAM
字节流特性带来的潜在问题(如数据复制或缓冲)的应用。
- 需要有序数据传输的应用:当应用程序需要确保数据按照发送的顺序被接收,并且每个数据包都是完整无损的,
-
创建套接字:创建
SOCK_SEQPACKET
类型的套接字时,需要在socket()
函数中指定相应的类型:
int sockfd = socket(AF_INET, SOCK_SEQPACKET, 0);
-
发送和接收数据:使用
SOCK_SEQPACKET
套接字发送和接收数据时,可以使用send()
和recv()
函数,或者使用write()
和read()
函数,数据会被组织成数据包的形式。 -
限制和兼容性:
SOCK_SEQPACKET
可能不是所有平台和所有协议都支持。在使用之前,需要确认目标平台和协议栈是否支持这种类型的套接字。 -
示例:使用
SOCK_SEQPACKET
套接字的服务器端和客户端程序会创建套接字、绑定地址、监听(对于服务器)或连接(对于客户端),然后通过发送和接收数据包进行通信。
SOCK_SEQPACKET
是一种较少使用的套接字类型,但它在需要有序、可靠且保留数据包边界的特定应用场景中非常有用。开发者在选择套接字类型时,需要根据应用的具体需求来决定是否使用SOCK_SEQPACKET
。 -
-
-
protocol
:指定使用的特定协议。通常设置为0,表示使用默认协议。
3、返回值:
-
成功时,返回新创建的套接字描述符(一个非负整数)。
-
失败时,返回-1,并设置全局变量
errno
以指示错误类型。
4、错误处理:
-
EAFNOSUPPORT
:指定的地址族不受支持。 -
EPROTONOSUPPORT
:指定的协议不受支持。 -
ENOMEM
:内存不足,无法分配套接字。
5、使用场景:
-
创建TCP或UDP服务器和客户端。
-
创建本地进程间通信的Unix域套接字。
6、示例代码:
int sockfd;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
7、注意事项:
- 套接字创建后,需要使用其他系统调用(如
bind()
,listen()
,connect()
,accept()
,send()
,recv()
等)来完成通信的建立和数据的传输。 - 套接字是非阻塞的,可以通过
fcntl()
函数设置为阻塞模式。 - 套接字的关闭应该使用
close()
函数。
socket()
函数是进行网络编程的起点,正确使用它可以为后续的网络通信打下基础。
三、套接字函数剖析 - bind(): 绑定套接字地址
在说bind 绑定套接字地址之前,我们先来说说 TIME_WAIT
。
1、TIME_WAIT
下图是TCP 连接关闭时的状态转移示意图,其它的我们可以不管,主要关注于 TIME_WAIT 状态 。
在 TCP 四次挥手过程中,第一个 FIN 和最后一个 ACK 包均由主动断开方发送,并且TIME_WAIT 状态也只会出现在主动断开方。
TIME_WAIT
是 TCP 协议中的一个状态,它与 TCP 连接的关闭过程有关。
-
TCP 连接关闭: TCP 连接是全双工的,这意味着数据可以在两个方向上独立传输。当一个方向上的数据传输完成时,该方向的连接将被关闭,而另一个方向上的数据传输可以继续进行。
-
四次挥手: TCP 连接的关闭通常通过一个称为“四次挥手”的过程来完成。在正常关闭的情况下,每个方向上的连接关闭都需要两个步骤:一个 FIN (结束) 标志位来指示数据发送完毕,以及对 FIN 的确认 (ACK)。
-
TIME_WAIT
状态: 当主动关闭方 (通常是客户端) 发送最后一个 ACK 并进入TIME_WAIT
状态时,它会等待足够的时间以确保被动关闭方 (通常是服务器) 接收到了最终的 ACK。这个状态确保连接可以正确关闭,即使在网络延迟或重传的情况下。 -
2MSL 等待:
TIME_WAIT
状态持续的时间是最大报文段寿命 (2MSL,Maximum Segment Lifetime) 的两倍。MSL 是一个网络参数,定义了任何 TCP 报文在网络上存活的最大时间。2MSL 确保即使在丢失最后一个 ACK 的情况下,如果被动关闭方重传 FIN,主动关闭方也能够响应。 -
资源占用: 在
TIME_WAIT
状态下,系统会为每个最近关闭的连接保留一些资源。如果应用程序频繁地打开和关闭连接,这可能会导致大量的资源占用。 -
端口耗尽问题: 由于每个进入
TIME_WAIT
状态的连接都需要占用一个本地端口,如果应用程序使用固定数量的端口,可能会导致端口耗尽问题。 -
避免
TIME_WAIT
: 在某些场景下,应用程序可以通过使用不同的端口或设置套接字选项来避免TIME_WAIT
状态,例如使用SO_REUSEADDR
选项允许应用程序重新使用本地地址和端口。 -
安全性:
TIME_WAIT
状态也有助于防止连接请求的重复,确保在相同的客户端和服务器地址以及端口上不会同时存在两个相同的连接。 -
特殊情况: 在某些特定的网络应用程序中,如 Web 服务器,可能会使用短连接,这种情况下
TIME_WAIT
状态可能会成为性能瓶颈。在这种情况下,可以使用持久连接或其他技术来减少TIME_WAIT
状态的影响。
TIME_WAIT
状态是 TCP 协议设计中的一个重要部分,确保了连接的可靠性和数据传输的完整性。然而,在某些高并发场景下,它也可能成为性能的瓶颈,需要通过适当的设计和配置来管理。
上面说到,我们可以通过使用不同的端口或设置套接字选项来避免 TIME_WAIT
状态,例如使用 SO_REUSEADDR
选项允许应用程序重新使用本地地址和端口。
接下来,我们来说说 SO_REUSEADDR
。
2、SO_REUSEADDR
SO_REUSEADDR
是一个套接字选项,用于控制套接字的行为,使其可以重新使用本地地址和端口组合。
以下是关于 SO_REUSEADDR
的详细说明:
- 套接字选项:
SO_REUSEADDR
是一个布尔值选项,可以通过setsockopt()
函数在套接字上设置。 - 函数原型:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
其中,level
通常是 SOL_SOCKET
,optname
是 SO_REUSEADDR
,optval
通常是一个指向 int
类型的指针,值为 1
以启用该选项。
-
默认行为: 默认情况下,当一个 TCP 套接字关闭时,它所绑定的本地地址和端口会进入
TIME_WAIT
状态,持续一定时间(通常是几分钟),在此期间,该地址和端口不能被重新使用。 -
启用
SO_REUSEADDR
: 启用SO_REUSEADDR
选项允许套接字立即重新使用本地地址和端口,即使它们之前已经被使用并处于TIME_WAIT
状态。 -
用途:
- 服务器应用程序通常在启动时绑定到一个固定的端口。如果服务器重启,它可能希望立即重新使用该端口,而不是等待
TIME_WAIT
状态结束。在这种情况下,启用SO_REUSEADDR
可以避免端口冲突。 - 在某些高并发的应用程序中,可能需要创建大量的短连接,启用
SO_REUSEADDR
可以减少因端口耗尽导致的问题。
- 服务器应用程序通常在启动时绑定到一个固定的端口。如果服务器重启,它可能希望立即重新使用该端口,而不是等待
-
安全性考虑:
- 启用
SO_REUSEADDR
可能会增加安全风险,因为它允许应用程序绕过TIME_WAIT
状态,这可能被用来实现某些类型的拒绝服务攻击。 - 在使用
SO_REUSEADDR
时,应用程序需要确保正确地处理并发连接,避免出现连接请求的冲突。
- 启用
-
与其他选项的比较:
SO_REUSEPORT
是另一个套接字选项,允许多个套接字监听相同的地址和端口,但它对每个连接使用不同的套接字。这与SO_REUSEADDR
不同,后者允许单个套接字绑定到一个已使用的地址和端口。
-
示例代码:
int sockfd;
int yes = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) < 0) {
perror("setsockopt(SO_REUSEADDR) failed");
exit(EXIT_FAILURE);
}
- 注意事项:
SO_REUSEADDR
只影响 TCP 和 UDP 套接字。- 在多播场景中,
SO_REUSEADDR
通常与IP_MULTICAST_LOOP
一起使用,允许套接字接收自己发送的多播数据包。
SO_REUSEADDR
是一个有用的套接字选项,可以在需要快速重用地址和端口时提高应用程序的灵活性和效率。然而,开发者在使用时需要权衡其带来的便利性和潜在的安全风险。
接下来,我们进入正题,说说 bind(): 绑定套接字地址。
3、bind(): 绑定套接字地址
bind()
函数用于将一个套接字(socket)与特定的端点地址绑定在一起。这个地址由IP地址和端口号组成,用于标识网络上的一个特定服务。以下是关于 bind()
函数的详细说明:
(1)、函数原型:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
(2)、参数:
-
sockfd
:由socket()
函数创建的套接字描述符。 -
addr
:指向sockaddr
结构的指针,该结构包含了要绑定的地址信息。对于IPv4,这通常是sockaddr_in
结构;对于IPv6,是sockaddr_in6
结构。sockaddr 有一个比较头疼的问题就是把 IP 和 Port 混在了一起,所以大部分情况下会使用 sockaddr_in。
struct sockaddr{ sa_family_t sin_family; char sa_data[14]; };
struct sockaddr_in{ sa_family_t sin_family; uint16_t sin_port; struct in_addr sin_addr; char sin_zero[8]; };
-
addrlen
:sockaddr
结构的大小,以字节为单位。
(3)、返回值:
- 成功时,返回0。
- 失败时,返回-1,并设置全局变量
errno
以指示错误类型。
(4)、错误处理:
EACCES
:地址已经在使用中,或者没有权限绑定到指定的端口。EADDRINUSE
:指定的地址已经被使用。EINVAL
:提供的套接字描述符无效,或者地址结构不正确。ENOMEM
:内存不足,无法完成绑定。
(5)、使用场景:
- 服务器端在启动时,需要将监听的端口与套接字绑定。
- 客户端在发起连接请求之前,有时也需要绑定一个本地地址。
(6)、绑定过程:
-
服务器或客户端首先创建一个套接字。
-
使用
bind()
将套接字与特定的IP地址和端口绑定。 -
服务器随后进入监听状态,等待客户端连接。
(7)、示例代码:
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY; // 绑定到所有可用的网络接口
server_addr.sin_port = htons(port); // 端口号转换为网络字节序
int result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (result < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
(8)、注意事项:
- 端口号0表示让系统选择一个临时的端口号。
INADDR_ANY
表示绑定到所有网络接口的IP地址。- 绑定操作通常在套接字创建之后、监听(对于服务器)或连接(对于客户端)之前进行。
- 确保
addrlen
参数正确,它应该是sockaddr
结构的实际大小。
bind()
函数是网络通信中的关键步骤之一,它确保了套接字能够监听或发起特定IP地址和端口上的通信。
四、套接字函数剖析 -listen(): 监听客户端连接请求
listen()
函数用于将一个套接字从主动连接模式转换为被动监听模式,使得该套接字可以接受来自客户端的连接请求,表示开始监听某一个端口。
1、监听队列
这是 TCP 三次握手的示意图:
通常而言,都是由客户端主动发起连接 ,当服务端收到了 SYN 包以后,那么连接状态就会由 LISTEN 转变为 SYN_RECV, 这个状态表示服务端三次握手还没有完成,所以也被称之为“半连接” ,只有当再次收到客户端发来的 ACK 确认包以后,该连接才会有 SYN_RECV 状态转 移至 ESTABLISHED 状态,表明连接已完全建立,双方均可收发数据。
TCP(传输控制协议)三次握手是建立一个可靠的连接所必须的过程。这个过程确保了两个端点(通常是一个客户端和一个服务器)能够开始双向通信。以下是关于TCP三次握手的详细说明:
-
第一次握手 - SYN:
- 客户端选择一个初始序列号(ISN,Initial Sequence Number)并发送一个带有 SYN 标志位的 TCP 段到服务器,以请求建立连接。这个段不包含任何数据,但它告诉服务器客户端准备好发送数据了。
-
第二次握手 - SYN-ACK:
- 服务器接收到客户端的 SYN 段后,如果同意建立连接,会发送一个带有 SYN 和 ACK(确认)标志位的 TCP 段作为响应。
- 服务器在这个段中也会选择自己的初始序列号,并将客户端的 ISN 加一作为 ACK 确认号,表示服务器已经接收到客户端的连接请求,并准备好发送数据。
-
第三次握手 - ACK:
- 客户端接收到服务器的 SYN-ACK 段后,会发送一个带有 ACK 标志位的 TCP 段作为最后的响应。
- 客户端将服务器的 ISN 加一作为 ACK 确认号,确认已经接收到服务器的连接请求。
-
连接建立:
- 完成这三次握手后,连接建立成功。客户端和服务器现在都拥有对方的初始序列号,可以开始发送数据。
-
序列号和确认号:
- 在 TCP 段中,序列号用于标识从源到目的地的数据的顺序,而确认号用于确认接收到的数据的序列号。
-
数据传输:
- 一旦连接建立,数据就可以在两个方向上流动。TCP 通过序列号和确认机制确保数据的可靠传输。
-
为什么要三次握手:
- 三次握手的主要目的是防止失效的连接请求突然“复活”导致已经关闭的连接被重新打开。例如,如果一个 SYN 段在网络中延迟了,并且在连接关闭后到达服务器,没有第三次握手,服务器可能会错误地尝试建立一个新连接。
-
安全性:
- 通过交换序列号,三次握手还提供了一定程度的安全性,因为它使得任何重放攻击都需要知道当前的序列号。
-
优化:
- 在某些情况下,如果应用程序需要同时发送数据并建立连接,可以使用同时带有 SYN 和数据的 TCP 段,这称为“同时打开”。
-
结束连接:
- 与建立连接不同,TCP 连接的关闭是一个四次挥手的过程,因为每个方向都可以独立关闭。
TCP三次握手是网络通信的基础,它确保了连接的可靠性和数据传输的顺序性和完整性。这个过程是 TCP 协议的核心特性之一,是实现网络通信的基础。
2、listen()
函数的详细说明
以下是关于 listen()
函数的详细说明:
(1)、函数原型:
int listen(int sockfd, int backlog);
(2)、参数:
sockfd
:已经绑定到特定地址的套接字描述符。backlog
:指定内核应该排队的最大未完成连接的数量。这个值应该足够大,以避免在高负载情况下丢失连接请求。
(3)、返回值:
- 成功时,返回0。
- 失败时,返回-1,并设置全局变量
errno
以指示错误类型。
(4)、错误处理:
EBADF
:sockfd
不是一个有效的套接字描述符。ENOTSOCK
:sockfd
不是一个套接字。EOPNOTSUPP
:套接字未绑定,或者不是可以监听的类型(例如非SOCK_STREAM
类型)。
(5)、使用场景:
- 主要用于服务器端,在
bind()
绑定地址之后,调用listen()
进入监听状态,等待客户端的连接请求。
(6)、监听过程:
- 服务器首先创建一个套接字并绑定到一个地址。
- 调用
listen()
使套接字变为被动监听模式。 - 服务器随后可以使用
accept()
函数接受客户端的连接请求。
(7)、示例代码:
int result = listen(sockfd, backlog);
if (result < 0) {
perror("listen failed");
exit(EXIT_FAILURE);
}
(8)、注意事项:
listen()
必须在套接字绑定到地址之后调用。backlog
参数定义了未完成连接的最大队列长度。如果连接请求超过这个数量,新的连接请求可能会被拒绝。- 对于
SOCK_STREAM
类型的套接字,listen()
是必要的,因为 TCP 是面向连接的协议。 - 对于 UDP 等无连接协议,
listen()
调用是不必要的,因为 UDP 通信不需要建立连接。
listen()
函数是服务器编程中的关键步骤,它使得服务器能够监听并接受来自客户端的连接请求,是建立网络通信的基础。
五、accept(): 接收客户端连接
accept()
函数用于接受一个已经建立的连接请求,通常在服务器端使用。当服务器调用 listen()
函数后,它会进入监听状态,等待客户端的连接请求。以下是关于 accept()
函数的详细说明:
(1)、函数原型:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
(2)、参数:
sockfd
:已经调用过listen()
的套接字描述符。addr
:(可选)指向sockaddr
结构的指针,用于存储连接客户端的地址信息。如果不需要客户端地址,可以设置为NULL
。addrlen
:(可选)指向socklen_t
类型的指针,用于存储addr
结构的大小。如果addr
是NULL
,这个参数也会被忽略。
(3)、返回值:
- 成功时,返回一个新的套接字描述符,用于与已连接的客户端通信。
- 失败时,返回-1,并设置全局变量
errno
以指示错误类型。
(4)、错误处理:
EBADF
:sockfd
不是一个有效的套接字描述符。EFAULT
:addr
或addrlen
指向的内存区域不可访问。EINVAL
:sockfd
未被listen()
调用过。ECONNABORTED
:连接请求被客户端中止。EMFILE
:进程的文件描述符表已满。ENFILE
:系统文件描述符表已满。
(5)、使用场景:
- 服务器端在调用
listen()
后,使用accept()
函数来接受客户端的连接请求。
(6)、连接过程:
- 服务器调用
accept()
后,会阻塞等待客户端的连接请求。 - 当客户端发起连接请求时,
accept()
函数会返回一个新的套接字描述符。 - 服务器可以使用这个新的套接字与客户端进行数据交换。
(7)、示例代码:
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_sockfd = accept(sockfd, (struct sockaddr *)&client_addr, &client_len);
if (client_sockfd < 0) {
perror("accept failed");
exit(EXIT_FAILURE);
}
(8)、注意事项:
accept()
可以是非阻塞的,如果套接字设置为非阻塞模式,并且没有待处理的连接请求,accept()
会立即返回错误EWOULDBLOCK
。- 如果
addr
和addrlen
参数被提供,accept()
会填充这些参数,返回连接客户端的地址和地址长度。 accept()
返回的套接字应该被用于与特定客户端进行通信,而原始的监听套接字(sockfd
)应该继续用于接受新的连接请求。- 在多线程或多进程服务器中,
accept()
返回的套接字可以被分配给一个工作线程或进程来处理。
accept()
函数是服务器端程序中接收客户端连接请求的关键环节,它使得服务器能够与客户端建立通信通道。
六、实战演练
现在让我们通过一个简单的TCP回射服务器程序,来体现前面所学的理论知识:
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
int main() {
// 创建Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定地址信息
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8000);
bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
// 监听连接
listen(sockfd, 5);
// 接收客户端连接
struct sockaddr_in cliaddr;
socklen_t cliaddrlen = sizeof(cliaddr);
int clifd = accept(sockfd, (struct sockaddr*)&cliaddr, &cliaddrlen);
// 读取客户端发送数据并回射
char buffer[1024];
ssize_t nbytes = read(clifd, buffer, sizeof(buffer));
write(clifd, buffer, nbytes);
// 关闭连接
close(clifd);
close(sockfd);
return 0;
}
通过上述代码,我们创建了一个TCP服务器,监听本机8000端口。当有客户端连接时,服务器将回射客户端发送的任何数据。你可以自行运行并结合服务器和客户端代码来体会Socket编程的魅力。
Socket编程虽然门槛不高,但内涵丰富、应用广泛。在分布式系统、游戏开发、物联网等诸多热门领域,Socket编程无疑将继续扮演着重要角色。如果你对网络编程有进一步的兴趣,不妨持续关注我们的后续文章,体会Socket编程在实战中的运用,掌握更多精彩内容。