目录
- 什么是Socket
- Socket的特点
- 网络通信三要素
- Socket和TCP的关系
- Socket编程的流程
- Socket 函数
- socket()函数原型
- 参数详解
- 返回值
- 参数举例
- Socket重要数据结构和函数解析
- sockaddr_in结构体
- sockaddr_in结构体定义
- bind函数
- 函数原型
- 参数详解
- 为什么需要bind()
- listen函数
- 函数原型
- 参数详解
- 返回值
- listen()函数的功能和工作流程
- listen() 和TCP三次握手的关系
- accept函数
- 函数原型
- 参数详解
- 返回值
- 阻塞与非阻塞模式
- 阻塞的处理过程
- recv函数
- 函数原型
- 参数详解
- 返回值
- send函数
- 函数原型
- 参数详解
- 返回值
- 发送数据长度问题
- connect函数
- connect阻塞与非阻塞
- 返回值
- close函数
- 返回值
- 作用
- TCP服务端的实现
- TCP客户端的实现
- 如何实现一对一聊天
- 常见的服务端高并发方案
- 多线程/多进程模型
- I/O多路复用
- 异步I/O
- Reactor 模式
什么是Socket
Socket是网络通信的“接口”,也就是程序和网络之间的中介。它为开发者提供了一套通用的函数或API,允许程序通过网络发送和接收数据。具体地说,Socket抽象了网络底层的细节,比如数据包的传输、协议的选择、错误处理等,开发者只需要使用Socket提供的函数来建立连接和传输数据,而不需要关心网络底层的实现。
Socket的特点
- 双向通信:支持全双工通信,可以同时发送和接收数据。
- 协议灵活性:可以使用多种协议,如TCP和UDP,适应不同的应用需求。
- 网络透明性:可以跨网络进行通信,不受地理位置限制。
- 可扩展性:能够处理多个连接,适合构建高性能网络应用。
- 数据流控制:TCP提供流量控制和可靠性保障,确保数据的完整性。
网络通信三要素
- IP地址(网络上主机设备的唯一标识)
用来寻找对应服务器的主机。 - 端口号(定位程序)
- 用于标示进程的逻辑地址,不同进程的有不同的端口号
- 相当于当前服务器(当前电脑)上对应的web应用程序
- 有效端口:0~65535,其中0~1024由系统使用或者称作保留端口,开发中建议使用1024以上的端口
- 传输协议(使用什么样的方式进行交互,通信规则)
常见协议:TCP、UDP
Socket和TCP的关系
Socket可以被视为对网络协议的封装或使用协议的工具。它提供了一组API,让开发者能够在应用层与网络办议(如TCP或UDP)进行交互。通过Socket,开发者可以方便地创建连接、发送和接收数据,而不需要直接处理底层协议的细节。因此,Socket是在应用程序和网络协议之间的一个抽象层。
Socket编程的流程
TCP服务端:
socket() — 类似于买了个电话机
bind() — 配置ip,端口号,协议 ,类似于配置手机号
listen() — 监听,类似于监听来电
accept() — 接收,接收链接,通过链接进行通话,类似于拿起话筒,通过链接通话。是创建一个新的socket进行通话。
read() — 可以读信息
write() — 也可以写信息
close() — 关闭连接
TCP客户端:
socket() — 类似于买了个电话机
connect() — 连接服务器,(三次握手)
write() — 发送消息
read() — 读信息
close() — 结束连接
Socket 函数
socket()函数是网络编程中创建套接字的基础函数,它用于创建一个套接字并返回文件描述符,该描述符可以用于后续的网络通信操作。
socket()函数原型
int socket(int domain,int type,int protocol);
参数详解
- domain(协议域/地址族): domain参数指定了通信使用的协议族
- type(套接字类型): type参数指定了套接字的类型
- SOCKSTREAM:流式套接字,提供面向连接、可靠的字节流服务(例如 TCP)
- SOCKDGRAM:数据报套接字,提供无连接、不可靠的消息传递服务(例如UDP)
- SOCKRAW:原始套接字,允许访问底层协议(通常用于定制的网络协议或数据包处理)
- protocol(协议):protocol参数指定具体使用的协议。通常设置为0,让系统自动选择与domain和type参数匹配的默认协议。
返回值
- 成功时,返回套接字的文件描述符(一个整数,表示套接字),后续通过这个文件描述符来操作该套接字。·
- 失败时,返回-1,并设置errno来指示具体错误原因。
参数举例
- Socket(AF_INET,SOCK_STREAM,0):
创建一个使用IPv4地址的TCP套接字,用于基于连接的通信(如Web、FTP、SSH等) - socket(AF_INET, SOCK_DGRAM, 0):
创建一个使用IPV4地址的UDP套接字,用于无连接的通信(如DNS查询、视频流传输等)。 - socket(AF_INET6, SOCK_STREAM,0):
创建一个使用IPv6地址的TCP套接字。 - socket(AF_UNIX,SOCK_STREAM,0):
创建一个UNIX域套接字,用于本地进程间通信。
Socket重要数据结构和函数解析
sockaddr_in结构体
sockaddr_in是用于处理IPv4地址的结构体,它用于存储网络套接字(Socket)的地址信息,通常在使用bind()、connect()、accept()等网络相关函数时会用到。
sockaddr_in是sockaddr结构体的一个专用版本,它更方便地处理IPv4地址,而sockaddr是通用的套接字地址结构体。由于网络函数通常要求参数类型是sockaddr,我们会将sockaddr_in类型强制转换为 sockaddr来使用。
为什么不直接用sockaddr?
sockaddr是通用的,但是不好用,很麻烦
sockaddr_in结构体定义
bind函数
bind()函数的主要作用是将套接字与特定的本地地址(IP地址)和端口号绑定起来,使得该套接字可以通过指定的地址和端口接收数据。bind()函数通常用于服务器端的套接字编程,以便为套接字分配一个固定的本地地址和端口。
函数原型
int bind(int sockfd,const struct sockaddr * addr,socklen_t addrlen);
参数详解
- sockfd:
套接字描述符,由socket()函数返回,表示要绑定的套接字。bind()函数使用这个套接字和本地地址进行绑定操作。 - addr:
这是一个指向sockaddr结构体的指针,它包含了要绑定的地址和端口信息。实际使用时,大多数情况下传递的是sockaddr_in结构体(IPv4)或者sockaddr_in6结构体(IPv6)的地址,并通过强制类型转换为sockaddr结构体。 - addrlen:
该参数指定地址的长度,通常为sizeof(struct sockaddr_in)或者sizeof(structsockaddr_in 6),具体取决于使用的是IPv4还是IPv6地址。
为什么需要bind()
- 服务器端:
在服务器端,bind()函数必须调用,它将套接字绑定到一个特定的端口和IP地址,使得客户端能够通过该地址和端口连接到服务器。如果不调用bind(),操作系统可能会自动分配一个临时的本地端口,这对于服务器来说是不可接受的,因为服务器需要一个固定的端口号以供客户端连接。 - 客户端:
在客户端编程中,bind()通常不是必需的。客户端套接字一般依赖于系统自动分配的本地端口,以便与服务器通信。如果有特殊需求(比如希望客户端使用特定的本地IP地址和端口),也可以调用bind()绑定。
listen函数
listen()函数在服务器端网络编程中用于将一个套接字设置为监听状态,使其能够接收来自客户端的连接请求。它是服务器端TCP套接字生命周期中一个关键步骤,用于处理被动套接字(即等待客户端连接的套接字)。
函数原型
int listen(int sockfd,int backlog);
参数详解
- sockfd(套接字文件描述符):
- 这是之前通过socket()函数创建的套接字的文件描述符,通常是一个使用SOCK_STREAM(TCP)类型的套接字。
- 该套接字必须已经通过bind()函数绑定到一个特定的IP地址和端口上。
- backlog(连接队列的最大长度):
- backlog参数指定内核为套接字维护的已完成连接队列和未完成连接队列的总长度。这个值表示最大可以同时等待处理的客户端连接请求数量。
- 已完成连接队列:保存已经完成TCP三次握手的连接。
- 未完成连接队列:保存正在等待完成三次握手的连接。
- 当队列满了之后,如果有新的连接请求到达,它们会被拒绝,客户端将收到ECONNREFUSED错误。
- 值可以根据服务器的预期负载进行设置。例如,高并发服务器可能设置较大的backlog值。
返回值
- 成功时返回0
- 失败时返回-1
listen()函数的功能和工作流程
-
将套接字从主动状态转换为被动状态:
- 调用listen()之前,服务器端的套接字处于主动状态,它只能用于发起连接请求(比如客户端连接其他服务器)。
- 调用listen()后,套接字被转换为被动套接字,即它不会发起连接,而是等待客户端连接请求。
-
维护连接请求队列:
- 内核为套接字维护两个队列:未完成队列和已完成队列。
- 未完成队列:存储那些已发起连接但尚未完成三次握手的客户端请求。
- 已完成队列:存储那些已经完成三次握手、等待accept()的客户端连接。
- backlog参数决定了这两个队列的总长度上限。如果队列已满,新的连接请求将被拒绝。
- 内核为套接字维护两个队列:未完成队列和已完成队列。
listen() 和TCP三次握手的关系
- 当服务器调用listen()后,它开始等待客户端的连接请求。客户端发起连接请求时,会进行TCP的三次握手过程:
- a.客户端发送SYN包给服务器,表示请求建立连接。
- b.服务器返回SYN-ACK包,表示同意建立连接。
- c.客户端返回ACK包,连接建立完成。
- 在三次握手完成之前,连接请求会被放入未完成队列;三次握手完成后,连接请求会被移到已完成队列。·
- 服务器可以通过accept()函数获取已完成连接队列中的客户端连接。
accept函数
accept()函数是服务器端套接字编程中的关键函数,用于从连接队列中取出等待的客户端连接,并为其创建一个新的套接字。通过accept(),服务器可以与客户端进行后续通信。
函数原型
int accept(int sockfd,struct sockaddr * addr,socklen_t *addrlen);
参数详解
- sockfd:
- 该参数是通过socket()函数创建,并通过bind()和listen()函数设定为监听模式的套接字描述符。
- sockfd是服务器用于监听客户端连接的套接字,不能直接用于与客户端通信。accept()会创建一个新的套接字,用于与客户端进行通信。
- addr:
例如:
struct sockaddr_in client_address;
socklen_t client_len = sizeof(client_address);
client_address用于存储客户端的地址,client_len存储结构体的大小。- 该参数是一个指向sockadr 结构体的指针,服务器使用它来存储客户端的地址信息(如IP地址和端口号)。
- addr通常指向一个sockaddr_in结构体(对于IPv4)或者sockaddr_in6结构体(对于 IPv6)。这些结构体包含了客户端的IP地址和端口信息。
- addrlen:
- 该参数是一个指向socklen_t类型变量的指针,传入时指定addr结构体的大小,函数返回时则保存实际的地址长度。
- 在调用accept()之前,你需要将它设置为sizeof(struct sockaddr_in)(或者sizeof(stru ctsockaddr_in6)对于IPv6)。函数返回后,addrlen会被更新为实际的地址大小。
返回值
- 成功时,accept()返回个新的套接字描述符(new_sockfd),该套接字专用于与该客户端的通信。
- 失败时,返回-1。
阻塞与非阻塞模式
- 阻塞模式:
在默认情况下,accept()是阻塞的。如果没有客户端连接,accept()会一直等待,直到有客户端连接进来。如果有客户端连接进来,accept()立即返回,返回值是与该客户端通信的新的套接字描述符。 - 非阻塞模式:
如果将套接字设置为非阻塞模式,accept()调用立即返回。如果没有等待的连接,accept()返回 -1,并设置errno为EAGAIN或EWOULDBLOCK。可以通过使用fcntl()函数将套接字设为非阻塞模式:
fcntl(sockfd,F_SETFL,O_NONBLOCK);
阻塞的处理过程
当服务器调用accept()后,如果当前没有客户端连接请求,accept()会进入阻塞状态,此时服务器会处于等待状态,直到有新的客户端尝试连接。
在阻塞期间,服务器的主线程无法执行其他操作,必须等待accept()返回新的连接。
- 当有客户端连接时:
- accept()解除阻塞,取出连接队列中的第一个连接请求。
- 返回用于与该客户端通信的新的套接字描述符。
- 如果连接队列中没有等待的连接请求,accept()会继续阻塞,直到新的连接到来。
recv函数
函数原型
ssize_t recv(int sockfd,void *buf,size_t len,int flag);
参数详解
- sockfd:
- 作用:这是一个套接字描述符,表示你希望从哪个套接字接收数据。通常是通过socket()函数创建,或者通过accept()函数获得的客户端连接套接字。
- buf:
- 作用:这是一个缓冲区指针,指向用于存储接收到的数据的内存区域。该内存区域由调用者分配,recv()函数将接收到的数据复制到这个缓冲区中。
- len:
- 作用:表示可以接收的最大字节数。即缓冲区buf的大小,recv()将最多接收len字节的数据,并将其存储到buf中。
- flags:
- 作用:这个参数提供额外的控制选项,影响recv()的行为。
- 一般填0,默认模式,没有特殊标志
返回值
- >0: 表示成功接收的数据字节数
- 0: 表示已被对方关闭(对于tcp连接)
- -1: 表示出现错误
send函数
send()函数用于通过已连接的套接字发送数据,通常在TCP网络编程中使用。它与recv()是对应的操作,recv()负责接收数据,而send()负责发送数据。
函数原型
ssize_t send(int sockfd,const void *buf,size_t len,int flag);
参数详解
- sockfd:
- 作用:这是一个套接字描述符,表示向哪个套接字发送数据。这个套接字通常是通过socket()函数创建,并通过connect()或accept()函数获得的有效连接。
- 2.buf:
- 作用:指向要发送的数据的缓冲区,即存放要通过网络传输的数据。发送的数据会从该缓冲区中读取。
- len:
- 作用:指定要发送的字节数,也就是缓冲区中数据的长度。send()函数会尝试发送len个字节的数据,但不保证一次就能发送全部数据。
返回值
- >0:表示成功发送的字节数,可能小于len,这意味着并未将全部数据一次性发送完。
- 0:在send()中返回0并不常见,通常不会用于指示成功的传输结束。
- -1:表示出现了错误,errno会设置为具体的错误码
发送数据长度问题
在网络编程中,特别是对于send()函数,不能保证一次调用send()会将所有数据发送完。特别是在传输大块数据时,send()可能只发送了一部分数据,然后返回实际发送的字节数。这时需要通过循环调用send()来确保所有数据都已发送。
connect函数
- TCP:在TCP中,connect()通过三次握手协议与服务器建立可靠的连接。
- a.客户端发送SYN(同步)报文,表示要发起连接。
- b.服务器回复SYN+ACK(同步+确认)报文,表示同意建立连接。
- c.客户端发送ACK(确认)报文,连接建立。
- UDP:在UDP中,connect()不会建立物理上的连接,因为UDP是无连接的协议。但它会设置默认的服务器地址,这样后续通过该套接字发送的数据报会自动发往该地址。
connect阻塞与非阻塞
- 阻塞模式:在默认情况下,connect()是阻塞的,即当客户端发起连接时,函数会阻塞,直到连接建立成功或超时失败。阻塞模式适用于简单的客户端应用程序。
- 非阻塞模式:当套接字设置为非阻塞模式时,connect()不会阻塞,而是立即返回。如果连接正在进行,则errno会被设置为EINPROGRESS。此时应用程序可以通过select()、poll()或epol1()等机制,等待连接完成。
返回值
- 0:表示连接成功
- -1:表示连接失败
close函数
close()函数用于关闭套接字,当一个应用程序不再需要与远程主机进行通信时,调用此函数可以释放相关的资源。
套接字的关闭过程涉及到网络连接的终止和资源的清理。
在网络编程中,特别是涉及TCP协议时,close()的行为比较复杂,因为它需要处理连接的安全终止,确保所有数据都成功传输。
返回值
- 0:表示成功关闭了套接字。
- -1:表示关闭套接字出现了错误。
作用
- 释放资源:当调用close()时,系统会回收该套接字占用的资源,包括网络缓冲区、套接字描述符等。否则会导致资源泄漏,最终可能耗尽系统资源。
- 通知对端:对于基于TCP的连接,close()函数会通知对端,本地应用程序已经关闭连接,不会再发送数据。TCP会通过四次挥手(four-wayhandshake)来确保连接的正常终止。
TCP服务端的实现
服务器一般都是用linux。
#include <iostream> //引入输入输出库
#include <sys/socket.h> //引入Socket库
#include <netinet/in.h> //引入Internet地址组
#include <unistd.h> //引入UNIX标准库
#include <cstring> //引入字符串操作库,用于memset()using namespace std;int main()
{int server_fd,new_socket; //声明服务器 Socket 和新的客户端Socketstruct sockaddr_in address; //声明用于存储地址信息的结构体int addrlen = sizeof(address); //地址结构体的大小const int PORT = 8080; //服务器监听的端口号//第一步:创建Socketserver_fd = socket(AF_INET,SOCK_STREAM,0);if(server_fd == 0){std::cerr << "Socket creation failer!" << endl;return 1;}//设置服务器地址信息address.sin_family = AF_INET; //使用IPV4地址族address.sin_addr.s_addr = INADDR_ANY; //允许接收来自如何IP地址的连接address.sin_port = htons(PORT); //设置监听的端口号//第二步:将Socket绑定到指定地址和端口if(bind(server_fd,(struct sockaddr*)&address,sizeof(address)) < 0){std::cerr << "Bind failed!" << endl;return 1;}//第三步:监听//开始监听传入的连接请求,队列长度为3if(listen(server_fd,3) < 0){cerr << "Listen failed!" << endl;return 1;}cout << "服务器开始监听用端口号:" << PORT << endl;//无限循环,接收客户端连接while(true){//第四步,接收new_socket = accept(server_fd,(struct sockaddr *)&address,(socklen_t*)&addrlen);if(new_socket < 0){cerr << "Accept failed!" << endl;continue;}//接收客户端发送的信息char buffer[1024] ={0}; //创建接收缓冲区//第五步:读取信息ssize_t bytes_read = recv(new_socket,buffer,sizeof(buffer),0); //接收数据if(bytes_read > 0){cout << "Client: "<< buffer << endl;//发送响应消息给客户端const char* message = "Hello from sever";send(new_socket,message,strlen(message),0);}//关闭与客户端的连接close(new_socket);}//关闭服务器socketclose(server_fd);return 0;
}
TCP客户端的实现
客户端一般在Windows下。这个客户端会连接到服务器,发送一条消息,并接收服务器的响应。
#include <iostream>
#include <string>
#include <WinSock2.h> //用于网络编程
#include <WS2tcpip.h> //TCP/IP协议的扩展库using namespace std;//预处理命令,我要加个库进来
#pragma comment(lib,"ws2_32.lib") // 链接ws2_32.lib库,这是使用Winsock2进行网络编程必需的int main()
{WSADATA wsaData; //用于WSAStartup的返回状态信息SOCKET sock = INVALID_SOCKET; //声明一个socket对象,并且初始化为无效套接字sockaddr_in server; //用于存储服务器地址信息的结构体int result; //用于存储WSAStartup的返回值//初始化Winsock库result = WSAStartup(MAKEWORD(2, 2), &wsaData);if (result != 0){cerr << "SAStartup失败,错误码" << result << endl;return 1;}//创建套接字(TCP,IPv4)sock = socket(AF_INET, SOCK_STREAM, 0);if (sock == INVALID_SOCKET){cerr << "套接字创建失败,错误码:" << WSAGetLastError() << endl;WSACleanup();//清理Winsock资源return 1;}//设置服务器的地址和端口server.sin_family = AF_INET;//使用IPv4地址server.sin_port = htons(8080); //设置端口号为8080,需要进行字节序的转换//使用inet_pton函数将文本形式的IP地址转换为二进制形式if (inet_pton(AF_INET, "192.168.88.130", &server.sin_addr) <= 0){std::cerr << "无效的地址" << endl;closesocket(sock);WSACleanup();return 1;}//连接到服务器if (connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0){cerr << "连接失败,错误码: " << WSAGetLastError() << endl;closesocket(sock);WSACleanup();return 1;}//发送消息到服务器string message = "Hello from client";send(sock, message.c_str(), message.size(), 0);//接收来自服务器的响应char buffer[1024] = { 0 };int byteRead = recv(sock, buffer, sizeof(buffer) - 1, 0);if (byteRead > 0){cout << "服务器:" << buffer << endl;}else if (byteRead == 0){cout << "连接被对方关闭" << endl;}else{cerr << "接收失败,错误码: " << WSAGetLastError() << endl;}//关闭closesocket(sock);WSACleanup();return 0;
}
如何实现一对一聊天
在C++的Socket编程中,实现一对一聊天的基本思路是构建一个客户端(Client)和一个服务端(Server),并让每个客户端之间通过服务器进行消息的转发。具体步骤如下:
- 服务端设计
服务端需要接受多个客户端的连接,并为每对用户建立专属的通信通道。实现流程如下:- 服务端启动并监听某个端口。
- 每当有客户端连接时,服务端接受连接并创建一个独立的线程或使用I/O多路复用(如select、epoll)来处理客户端请求。
- 服务端维护一个客户端的连接表,当两个客户端匹配时,将彼此的消息进行转发。
- 实现聊天消息的收发和转发逻辑。
流程:当想要给D发消息的时候,服务器接收到A的信息可以直接调用D的socket发过去给D
常见的服务端高并发方案
在C++Socket编程中,实现服务端高并发的常见方案主要有以下几种:
多线程/多进程模型
每个连接由一个独立的线程或进程处理,能够比较简单地实现并发处理。
- 优点:代码易于理解,编写起来较为简单。
- 缺点:线程或进程开销较大,在高并发场景下,大量的线程/进程会带来系统资源消耗和性能瓶颈,特别是在数千甚至数万个连接时。
I/O多路复用
I/O多路复用可以通过少量线程处理大量并发连接,常用的方法包括:
- select:通过一个文件描述符集合监视多个文件描述符是否有I/O事件。
- 优点:简单、易用,跨平台支持好。
- 缺点:性能不佳,处理大量连接时,每次调用select都要遍历整个描述符集合,效率低。
- poll:与select类似,但没有文件描述符数量限制。
- 优点:避免了select的文件描述符限制。
- epoll(Linux专用):epoll是Linux特有的I/O多路复用机制,性能更好,适合处理大量并发连接。(重要)
- 优点:不会遍历所有文件描述符,性能优异,适用于高并发场景。
- 缺点:仅限于Linux系统,学习曲线稍高。
异步I/O
异步I/O通过事件驱动机制,程序不需要等待I/O操作的完成,而是注册事件,事件触发时进行处理。常见的异步I/O实现包括:
- Windows:使用IOCP(l/OCompletionPort)实现异步I/O处理。
- Linux:可以使用libaio或者基于epoll实现的异步I/O。
- 优点:真正的异步,无需阻塞等待I/O操作,性能高,适合高并发。
- 缺点:编写异步代码较复杂,调试较困难。
Reactor 模式
Reactor模式是I/O多路复用的一种常见实现模式。它通过注册I/O事件,将事件分发给事件处理器。
- 典型实现:使用epoll或select监听事件,再结合事件处理回调函数进行处理。
- 优点:能够较好地处理大量并发连接,灵活性高。
- 缺点:编写和理解较为复杂,需要维护事件循环和回调函数。