监听套接字的创建流程
在网络编程中,listen
套接字(通常称为“监听套接字”)是服务器端用于接收客户端连接请求的特殊套接字,是 TCP 服务器建立连接过程中的核心组件。下面我们就来简单看一下监听套接字创建的过程
-
创建流程:
服务器通过socket()
函数创建一个基础套接字(类型为SOCK_STREAM
,表示 TCP 协议),然后通过bind()
函数将其绑定到特定的 IP 地址和端口,最后调用listen()
函数将其转换为 监听套接字。
示例代码框架:int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // 创建基础套接字 bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)); // 绑定地址端口 listen(listen_fd, 5); // 转换为监听套接字,允许5个未完成连接的排队
-
核心作用:
监听套接字专门用于 接收客户端的connect()
连接请求,但它本身 不参与实际的数据传输。当客户端发起连接时,服务器通过accept从监听套接字的队列中取出一个链接请求,并创建新的“连接套接字”处理后续的数据读写。
相关系统调用简单回顾
socket()
函数——创建套接字
函数原型:
int socket(int domain, int type, int protocol);
作用:创建一个套接字描述符(类似文件描述符),用于后续的网络通信,返回值为套接字描述符(fd
),失败返回 -1
。
参数解析:
-
domain
(协议族/地址族)
指定套接字使用的网络协议族,决定了数据传输的地址格式(如 IPv4、IPv6 等)。常见取值:AF_INET
:IPv4 协议族(最常用),对应的地址结构为struct sockaddr_in
。AF_INET6
:IPv6 协议族,对应的地址结构为struct sockaddr_in6
。AF_UNIX
/AF_LOCAL
:本地进程间通信(Unix 域套接字),用于同一台主机上的进程通信。
示例:使用 IPv4 协议族
int sockfd = socket(AF_INET, ..., ...);
-
type
(套接字类型)
指定数据传输的方式(面向连接/无连接),常见取值:SOCK_STREAM
:流式套接字(TCP 协议),特点是 可靠、有序、面向连接,适用于需要保证数据完整性的场景(如 HTTP、FTP)。SOCK_DGRAM
:数据报套接字(UDP 协议),特点是 无连接、不可靠、效率高,适用于实时性要求高的场景(如视频通话、DNS)。SOCK_RAW
:原始套接字,允许直接操作底层协议(如 IP 协议),通常用于开发网络工具(如 ping、traceroute),需要 root 权限。
示例:创建 TCP 套接字
int tcp_sock = socket(AF_INET, SOCK_STREAM, 0);
-
protocol
(协议)
指定具体使用的协议,通常设为0
(让系统根据前两个参数自动选择默认协议):- 当
domain=AF_INET
且type=SOCK_STREAM
时,默认协议是IPPROTO_TCP
(TCP 协议)。 - 当
domain=AF_INET
且type=SOCK_DGRAM
时,默认协议是IPPROTO_UDP
(UDP 协议)。
示例:显式指定 TCP 协议(等价于设为 0)
int tcp_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
- 当
bind()
函数——为一个套接字绑定地址和端口
函数原型:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
作用:将套接字(sockfd
)与特定的 IP 地址 和 端口号 绑定,使套接字“锚定”到一个具体的网络端点,失败返回 -1
。
参数解析:
-
sockfd
由socket()
函数返回的套接字描述符,即要绑定的目标套接字。 -
addr
(地址结构)
指向一个通用地址结构struct sockaddr
的指针,实际使用时需根据socket()
的domain
参数转换为对应类型的地址结构:- IPv4 场景:使用
struct sockaddr_in
(需强制转换为struct sockaddr*
),结构定义:struct sockaddr_in {sa_family_t sin_family; // 协议族,必须为 AF_INETin_port_t sin_port; // 端口号(网络字节序)struct in_addr sin_addr; // IPv4 地址(网络字节序)unsigned char sin_zero[8]; // 填充字段,通常设为 0 };struct in_addr {uint32_t s_addr; // IPv4 地址(32位无符号整数,网络字节序) };
- 关键设置:
sin_family
必须与socket()
的domain
一致(如AF_INET
)。sin_port
是端口号(范围 1-65535,0 为临时端口),需用htons()
转换为 网络字节序(大端序)。sin_addr.s_addr
是 IPv4 地址,常用值:INADDR_ANY
:绑定到本机所有网络接口的 IP 地址(服务器常用,无需指定具体 IP)。- 具体 IP 地址:如
inet_addr("192.168.1.100")
(需转换为网络字节序)。
- IPv4 场景:使用
-
addrlen
指定addr
指向的地址结构的大小(字节数),通常通过sizeof()
获取:socklen_t len = sizeof(struct sockaddr_in);
示例代码(服务器绑定 IPv4 地址)
// 创建 IPv4 + TCP 套接字
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {perror("socket failed");exit(1);
}// 配置 IPv4 地址结构
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr)); // 初始化清零
server_addr.sin_family = AF_INET; // 协议族:IPv4
server_addr.sin_port = htons(8080); // 端口号:8080(转换为网络字节序)
server_addr.sin_addr.s_addr = INADDR_ANY; // 绑定到本机所有 IP// 绑定套接字
if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {perror("bind failed");close(listen_fd);exit(1);
}
关键注意事项
- 网络字节序:端口号和 IP 地址必须用
htons()
/htonl()
转换为网络字节序(大端序),避免因主机字节序差异导致的错误。 - 端口占用:若
bind()
失败且errno=EADDRINUSE
,表示端口已被占用,需更换端口或等待占用释放。 - 客户端是否需要
bind()
:客户端通常不需要显式bind()
,系统会自动分配临时端口;服务器必须bind()
到固定端口,否则客户端无法找到服务器。
通过 socket()
和 bind()
的参数配置,可灵活定义套接字的通信协议、类型和绑定的网络端点,是网络编程的基础步骤。
listen()
函数——将一个普通的套接字转化为监听套接字
在基于TCP协议的网络编程中,listen
函数是服务器端编程的关键函数之一,用于将一个普通的套接字转化为监听套接字,从而监听客户端的连接请求。以下是对listen
函数功能的详细介绍:
1. 函数原型
int listen(int sockfd, int backlog);
2. 参数说明
sockfd
:这是由socket
函数创建并通过bind
函数绑定了本地IP地址和端口号的套接字描述符。它是一个整数,标识了要进行监听操作的套接字。只有在bind
操作成功后,sockfd
才能作为listen
函数的参数,用于将其转换为监听套接字。backlog
:该参数指定了未完成连接队列(incomplete connection queue)和已完成连接队列(established connection queue)的总长度上限 。- 未完成连接队列:在TCP三次握手过程中,当客户端发送SYN报文发起连接请求后,服务器接收到该请求,会将这个连接请求暂时放入未完成连接队列中,直到完成三次握手,建立起完整的连接。
- 已完成连接队列:完成三次握手的连接会被移动到已完成连接队列中,等待服务器调用
accept
函数来取出并处理。 - 若队列已满,新的连接请求可能被服务器忽略(不同操作系统处理方式略有差异,一般客户端会收到连接超时或连接被拒绝的响应 )。在现代Linux系统中,
backlog
的实际含义是两个队列长度总和的上限,但具体行为还受到内核参数(如somaxconn
)的影响。
3. 函数功能实现
- 将套接字转换为监听套接字:调用
listen
函数后,指定的套接字(sockfd
)就从一个普通套接字转变为监听套接字,处于监听状态,专门用于接收客户端的连接请求。监听套接字本身并不参与实际的数据传输,它只负责监听并处理连接请求。 - 管理连接队列:通过
backlog
参数,listen
函数控制着服务器可以同时处理的未完成连接和已完成连接的数量。这有助于服务器合理管理资源,避免因连接请求过多而导致系统资源耗尽。
4. 返回值
- 成功调用
listen
函数时,会返回0 ,表示监听操作设置成功,套接字已成功转换为监听状态。 - 失败时,会返回-1,并设置
errno
来指示错误原因。常见的错误原因包括:EBADF
:sockfd
不是一个有效的文件描述符。EINVAL
:sockfd
没有绑定到一个地址,或者backlog
参数的值无效(比如小于0)。EMFILE
:当前进程已经打开了太多的文件描述符,达到了系统限制,导致无法将套接字设置为监听状态。
5. 示例代码:创建一个监听套接字
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>#define PORT 8080
#define BACKLOG 5int main() {int sockfd, new_socket;struct sockaddr_in server_addr, client_addr;socklen_t client_addr_len = sizeof(client_addr);// 创建套接字sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd == -1) {perror("Socket creation failed");exit(EXIT_FAILURE);}// 配置服务器地址server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = INADDR_ANY;server_addr.sin_port = htons(PORT);// 绑定套接字if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {perror("Bind failed");exit(EXIT_FAILURE);}// 将套接字设置为监听状态if (listen(sockfd, BACKLOG) < 0) {perror("Listen failed");exit(EXIT_FAILURE);}...close(socket);return 0;
}
在上述代码中,通过listen
函数将socket
创建并bind
绑定后的套接字设置为监听状态,随后通过accept
函数来接受客户端的连接请求。
总的来说,listen
函数是TCP服务器端实现并发连接处理的基础,它使得服务器能够高效地管理客户端的连接请求,为后续的数据交互奠定基础。
的创建
accept()
函数——从监听套接字的链接队列中获取请求
在基于TCP协议的网络编程中,accept
函数用于从监听套接字的已完成连接队列中取出一个客户端连接请求,并创建一个新的套接字,用于与该客户端进行通信。以下是对accept
函数参数的详细解析:
1. 函数原型
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
2. 参数说明
sockfd
- 含义:监听套接字的文件描述符
- 作用:
accept
函数会检查sockfd
对应的监听套接字的已完成连接队列,从中取出一个已完成三次握手的客户端连接请求。如果已完成连接队列为空,accept
函数默认会阻塞,直到有新的连接请求到来(也可以通过设置套接字为非阻塞模式改变这一行为)。
addr
- 含义:输出型参数,accept从监听套接字的链接队列中取出一个请求之后,就把请求所属客户端的地址信息存储在一个 struct sockaddr结构体中,然后将这个结构体的指针作为输出型参数传递给进程。
- 示例(以IPv4为例):
struct sockaddr_in client_addr;
// 作为参数传入accept函数前,不需要初始化client_addr结构体
accept(listen_fd, (struct sockaddr *)&client_addr, &client_addr_len);
- **填充内容**:当`accept`函数成功返回时,该结构体中会被填入客户端的相关地址信息,包括客户端的IP地址和端口号。对于`struct sockaddr_in`结构体,`sin_addr`成员存储客户端的IPv4地址(网络字节序),`sin_port`成员存储客户端的端口号(网络字节序)。
addrlen
- 含义:输入输出型参数,输入时
accept
函数通过该参数知道要填充的结构体的大小,以避免越界写入;当accept
函数成功返回时,accept函数会将addr
结构体中的客户端地址信息的长度写入addrlen
中
- 含义:输入输出型参数,输入时
示例(以IPv4为例):
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
accept(listen_fd, (struct sockaddr *)&client_addr, &client_addr_len);
3. 返回值
- 成功时:
accept
函数返回一个新的套接字描述符,该套接字用于与刚刚接受的客户端进行后续的数据读写操作。这个新套接字与监听套接字不同,监听套接字继续用于监听新的连接请求,而新套接字专门服务于一个特定的客户端连接。 - 失败时:
accept
函数返回-1,并设置errno
来指示错误原因。常见的错误原因包括:EBADF
:sockfd
不是一个有效的文件描述符。EAGAIN
或EWOULDBLOCK
:如果套接字被设置为非阻塞模式,且当前已完成连接队列为空,accept
函数会返回这个错误。ECONNABORTED
:由于某些原因(如客户端异常关闭连接),已完成连接队列中的连接被中止,accept
函数会返回这个错误。
基于上面的系统调用,实现一个简单的tcpServer
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <unistd.h>const static int default_backlog = 6;enum
{Usage_Err = 1,Socket_Err,Bind_Err,Listen_Err
};#define CONV(addr_ptr) ((struct sockaddr *)addr_ptr)class TcpServer
{
public:TcpServer(uint16_t port) : _port(port), _isrunning(false){}// 创建一个监听套接字void Init(){// 1. 创建socket, file fd, 本质是文件_listensock = socket(AF_INET, SOCK_STREAM, 0);if (_listensock < 0){exit(0);}int opt = 1;// 允许地址和端口复用setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));// 2. 填充本地网络信息并bindstruct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = htonl(INADDR_ANY);// 2.1 bindif (bind(_listensock, CONV(&local), sizeof(local)) != 0){exit(Bind_Err);}// 3. 设置socket为监听状态,tcp特有的if (listen(_listensock, default_backlog) != 0){exit(Listen_Err);}}void ProcessConnection(int sockfd, struct sockaddr_in &peer){uint16_t clientport = ntohs(peer.sin_port);std::string clientip = inet_ntoa(peer.sin_addr);std::string prefix = clientip + ":" + std::to_string(clientport);std::cout << "get a new connection, info is : " << prefix << std::endl;while (true){char inbuffer[1024];// 将客户端发来的信息从接收缓冲区中读取到inbuffer中ssize_t s = ::read(sockfd, inbuffer, sizeof(inbuffer)-1);if(s > 0){inbuffer[s] = 0;std::cout << prefix << "# " << inbuffer << std::endl;std::string echo = inbuffer;echo += "[tcp server echo message]";// 将客户端发来的数据再发回去write(sockfd, echo.c_str(), echo.size());}else{std::cout << prefix << " client quit" << std::endl;break;}}}void Start(){_isrunning = true;while (_isrunning){// 4. 获取连接struct sockaddr_in peer;socklen_t len = sizeof(peer);int sockfd = accept(_listensock, CONV(&peer), &len);if (sockfd < 0){continue;}// 走到这说明成功从监听套接字的链接队列中获取到了一个链接,我们下面就调用ProcessConnection来处理这个链接ProcessConnection(sockfd, peer);}}~TcpServer(){}private:uint16_t _port;int _listensock; // TODObool _isrunning;
};using namespace std;void Usage(std::string proc)
{std::cout << "Usage : \n\t" << proc << " local_port\n"<< std::endl;
}
// ./tcp_server 8888
int main(int argc, char *argv[])
{if (argc != 2){Usage(argv[0]);return Usage_Err;}// 获取命令行中的端口号uint16_t port = stoi(argv[1]);std::unique_ptr<TcpServer> tsvr = make_unique<TcpServer>(port);tsvr->Init();tsvr->Start();return 0;
}
注: setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
这段代码的作用主要是给监听套接字设置允许端口和地址复用,提升TCPServer的稳定性。详细解释如下:
SO_REUSEADDR(重用地址)
核心作用:允许绑定到已处于 TIME_WAIT 状态的端口。
场景:服务器程序重启时,如果之前的连接还处于 TIME_WAIT 状态(TCP 连接关闭后会保留一段时间,确保数据传输完成),默认情况下新的 bind() 会失败(提示 “地址已在使用”)。设置该选项后,即使端口处于 TIME_WAIT,也能成功绑定。
SO_REUSEPORT(重用端口,Linux 3.9+ 支持)
核心作用:允许多个套接字绑定到 同一个端口(但通常要求这些套接字属于不同进程,或有特殊权限)。
场景:常用于多进程 / 多线程服务器,多个进程可以同时监听同一个端口,内核会将新连接负载均衡到这些进程,提高并发处理能力。