网络通信的本质是:两个网络进程进行通信。 上节我们提到,网络中ip地址可以标识唯一的一台主机,而主机中的网络进程是通过端口号(port)确定进程唯一性的。进一步讲,在网络通信中,通过ip+port来唯一标识某个主机上的某个进程。
端口号是传输层协议的内容,它是一个2字节16bit的整数,用于标识一个进行网络通信的进程,告知OS当前数据传递给哪一个进程。一个端口号只能标识一个进程,一个进程可以绑定多个端口号。端口号可由用户指定,也可由OS自动分配。
理解“端口号”和“进程PID”
既然需要标识网络通信中唯一一个进程,那么为什么不用系统中的进程PID,而是重新定义了一个端口号呢?
- 跨计算机通信: 进程PID是针对每台计算机上运行的进程的,不同计算机上可能存在相同的PID。在网络中,需要一种机制来标识不同计算机上运行的进程,因此需要使用全局唯一的标识符。
- 动态性: 进程在运行时可以创建和销毁,其PID也可能会更改。如果使用PID作为标识符,那么在进程重新启动后,其他进程无法识别它,这会导致通信中断。
- 端口号的多样性: 端口号是一种广泛用于网络通信的标识符,它不仅用于标识进程,还可以用于标识不同类型的服务。这种多样性使得不同类型的通信可以共存于同一台计算机上,而无需担心冲突。
- 网络层次: 在计算机网络中,通信涉及多个层次,从物理层到应用层。端口号位于传输层(通常是TCP或UDP协议),而进程PID是操作系统内核层的概念。因此,端口号更适合在传输层标识和管理不同进程之间的通信。
此处我们先简单直观的认识一下UDP和TCP两种协议,以便更好掌握socket套接字编程。
UDP协议 (User Datagram Protocol,用户数据报协议)
TCP协议(Transmission Control Protocol,传输控制协议)
这里先简单复习一下系统大小端字节序的概念
小端:低位在低地址,高位在高地址
大端:低位在高地址,高位在低地址
下面是4字节整数0x12345678
在内存中小端与大端不同的字节序。
不同的主机可能以不同的字节序存储多字节数据,那么,一台小端机器和一台大端机器就不能直接将数据传递给对方了,双方都不认识对方的数据。
为了解决这一问题,TPC/IP协议规定:网络数据流采用大端字节序。 即:小端机器向网络中发送数据,需要先将数据转成大端字节序,从网络中获取数据也需将数据先转成小端再使用。而大端就直接收发数据即可,无需转换。
网络通信双方传输和接收的核心数据,一般由系统调用自动做字节序的转换,而需要用户手动转换字节序的一般是通信的端口号和ip地址。
#include
uint32_t htonl(uint32_t hostlong); // 主机转网络(4byte)
uint16_t htons(uint16_t hostshort); // 主机转网络(2byte)
uint32_t ntohl(uint32_t netlong); // 网络转主机(4byte)
uint16_t ntohs(uint16_t netshort); // 网络转主机(2byte)
**Linux中的socket套接字是一种用于在不同进程之间进行通信的机制,它允许在同一台或不同计算机上的进程之间进行数据交换。**Socket API是一种应用层与传输层之间的接口,它使开发者能够创建网络应用程序,如客户端-服务器应用程序。
1️⃣
Linux下一切皆文件,因此网络通信本质上也是进程打开一个文件,获取一个文件描述符,并向这个文件描述符中传输或获取网络数据。这是网络通信的第一步,用到的是socket
这个系统接口。
#include
#include
int socket(int domain, int type, int protocol);
参数:
domain
:通信类型,IPv4通信:AF_INET, IPV6通信:AF_INET6, 本地通信:AF_UNIXtype
:传输数据类型,UDP:SOCK_DGRAM(数据报), TCP:SOCK_STREAM(字节流)protocol
:协议类型,传入0可根据type自动推导返回值:
一个套接字的文件描述符,后续通过该文件描述符传输或获取数据
2️⃣
第二步要绑定网络进程的地址,以便其它进程能找到该进程,实现通信。socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、 IPv6,以及UNIX Domain Socket(本地进程通信)。然而, 各种协议的地址格式并不相同。
绑定本地地址用到的系统接口是bind
,需要用户先定义并填充一个地址结构体,再用bind
接口绑定到系统当中,值得注意的是,bind
接口的第二个参数addr的类型是struct sockaddr *
,因为addr可能指向不同的地址结构体类型,这里传入统一类型的指针,再内部判断指针指向的空间头部的地址类型(AF_INET/AF_UNIX),即可判断地址结构体的类型,此处类似cpp多态的思想。
#include
#include
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd
:接口socket创建的文件描述符
addr
:用户定义的地址结构体指针
addrlen
:结构体的长度返回值:
成功返回0,失败返回-1并设置错误码errno。
struct sockaddr_in的代码结构
UDP协议规定的是无连接的网络通信,通信双方无需连接直接通过地址找到对方并通信,传输数据面向数据报。优点是代码实现简单,缺点是传输不稳定可靠。
⭕UDP协议用于收发数据的系统接口:
接收数据recvfrom
#include
#include
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
参数:
sockfd
:套接字文件描述符,用于指定要从哪个套接字接收数据。
buf
:一个指向用于存储接收数据的缓冲区的指针。
len
:接收缓冲区的长度,即可接收的最大字节数。
flags
:控制接收操作的标志位,通常设置为0。
src_addr
:一个指向struct sockaddr
类型的指针,用于填充发送数据方的地址信息。这个参数可以为NULL,如果不关心对方的地址信息。
addrlen
:一个指向socklen_t
类型的指针,用于指定src_addr
缓冲区的长度。在调用recvfrom
之前,你需要将addrlen
设置为src_addr
缓冲区的大小。返回值:
成功返回值接收到的字节数。如果发生错误,返回值为 -1,并且可以使用
errno
来获取错误代码。
发送数据sendto
#include
#include
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
参数:
sockfd
:套接字文件描述符,用于指定要发送数据的套接字。buf
:一个指向包含要发送数据的缓冲区的指针。len
:要发送的数据的长度(以字节为单位)。flags
:控制发送操作的标志位,通常设置为0。dest_addr
:一个指向struct sockaddr
类型的指针,用于指定数据的目标地址。这个参数通常用于指定接收方的地址信息。addrlen
:一个socklen_t
类型的整数,用于指定dest_addr
缓冲区的长度。返回值:
成功返回发送的字节数。如果发生错误,返回值为 -1,并且可以使用
errno
来获取错误代码。
// server.cc
#include
#include
#include
#include
#include "server.hpp"
#include "err.hpp"
// 该服务器完成工作:将客户端数据接收并原封不动地发挥给客户端即可
void Usage()
{
// 使用手册
std::cout << "Please enter the correct format: "
<< "./server [port]" << std::endl;
}
std::string EchoService(const std::string& msg)
{
return msg;
}
// ./server [port] (port为该网络进程的端口号)
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage();
exit(USAGE_ERR);
}
// 将服务器封装成一个类,先理清调用逻辑
// 向服务器传入用户指定的端口号,以及业务处理函数(只需将消息收到并返回给即可)
std::unique_ptr<UdpServer> us_ptr(new UdpServer(atoi(argv[1]), EchoService));
us_ptr->Initial(); // 初始化服务器
us_ptr->Start(); // 启动服务器
return 0;
}
// server.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "err.hpp"
const int MSG_BUF_SIZE = 1024;
using serv_func_t = std::function<std::string(const std::string &str)>;
class UdpServer
{
public:
// 构造函数
UdpServer(int port, serv_func_t service) : _port(port), _service(service)
{
}
// 初始化服务器
void Initial()
{
// 1. 创建socket套接字(协议族,声明是哪种通信、哪种协议)
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
// 创建套接字失败
std::cerr << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
_sockfd = sockfd;
std::cout << "socket creat success: " << _sockfd << std::endl;
// 2. 绑定IP地址和端口号(地址族,标定网络通信地址)
// 2.1 填充sockaddr_in各个字段
struct sockaddr_in sin;
bzero(&sin, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons(_port); // 主机转网络字节序
// sin.sin_addr.s_addr = inet_addr(_ip.c_str());
// in_addr_t inet_addr(const char *cp);
// 将点分字符串形式的ip地址转成四字节整数形式,并且从主机字节序转换为网络字节序
// 云服务器可能有多个ip地址,不允许用户指定某一个,用INADDR_ANY表示该服务器的任意ip
// 表示只要发到该服务器上的信息都可以接收
sin.sin_addr.s_addr = INADDR_ANY;
// 2.2 调用bind绑定到系统
if (bind(_sockfd, (struct sockaddr *)&sin, sizeof(sin)) < 0)
{
// 绑定失败
std::cerr << strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "socket bind success: "
<< "port->" << _port << std::endl;
}
// 启动服务器
void Start()
{
// 不断地接收客户端数据,并将数据返回给客户端
while (true)
{
// 1. 数据接收
char msg_buf[MSG_BUF_SIZE];
memset(msg_buf, 0, MSG_BUF_SIZE);
// 客户端地址信息,recv会自动填充
struct sockaddr_in cln;
bzero(&cln,sizeof(cln));
socklen_t len;
ssize_t rn = recvfrom(_sockfd, msg_buf, sizeof(msg_buf) - 1, 0, (struct sockaddr *)&cln, &len);
if (rn < 0)
{
// 接收失败
std::cerr << strerror(errno) << std::endl;
exit(RECV_ERR);
}
msg_buf[rn] = '\0';
// 2. 业务处理
std::cout << "[" << inet_ntoa(cln.sin_addr) << ":" << ntohs(cln.sin_port) << "] ";
std::cout << "#用户输入指令# " << msg_buf << std::endl;
std::string respond = _service(msg_buf);
// 3. 数据传回客户端
ssize_t sn = sendto(_sockfd, respond.c_str(), respond.size(), 0, (struct sockaddr *)&cln, sizeof(cln));
if (sn < 0)
{
std::cerr << errno << " " << strerror(errno) << std::endl;
exit(SEND_ERR);
}
}
}
private:
int _sockfd; // 套接字文件描述符
uint16_t _port; // 服务器端口号
serv_func_t _service;// 业务处理接口
};
//client.cc
#include
#include
#include "client.hpp"
#include "err.hpp"
void Usage()
{
std::cout << "Please enter the correct format: "
<< "./client [server's ip] [server's port]" << std::endl;
}
// ./client [ip] [port] (由用户指定服务器的ip和port)
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage();
exit(USAGE_ERR);
}
std::unique_ptr<UdpClient> uc_ptr(new UdpClient(argv[1], atoi(argv[2])));
uc_ptr->Initial();
while (true)
{
std::cout << "ENTER:> ";
std::string msg;
std::getline(std::cin, msg);
// 用户不断发送消息并接收从服务器返回的数据
uc_ptr->Send(msg);
uc_ptr->Recv();
}
return 0;
}
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include "err.hpp"
const int MSG_BUF_SIZE = 1024;
static struct sockaddr_in tmp;
static socklen_t len;
class UdpClient
{
public:
UdpClient(std::string svr_ip, uint16_t svr_port)
: _svr_ip(svr_ip), _svr_port(svr_port)
{
// 填充服务器的地址信息
bzero(&_svr, sizeof(_svr));
_svr.sin_family = AF_INET;
_svr.sin_port = htons(_svr_port);
_svr.sin_addr.s_addr = inet_addr(_svr_ip.c_str());
}
void Initial()
{
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
// 创建套接字失败
std::cerr << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
_sockfd = sockfd;
// 2. 绑定(由操作系统自动绑定地址,端口号由OS分配)
}
// 向服务端发送消息
void Send(std::string &msg)
{
ssize_t n = sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr *)&_svr, sizeof(_svr));
if (n < 0)
{
std::cerr << strerror(errno) << std::endl;
exit(SEND_ERR);
}
}
// 接收服务端发回的消息
void Recv()
{
char msg_buf[MSG_BUF_SIZE];
memset(msg_buf, 0, MSG_BUF_SIZE);
int n = recvfrom(_sockfd, msg_buf, sizeof(msg_buf) - 1, 0, (struct sockaddr *)&tmp, &len);
if (n < 0)
{
std::cerr << strerror(errno) << std::endl;
exit(RECV_ERR);
}
msg_buf[n] = '\0';
std::cout << "[" << inet_ntoa(_svr.sin_addr) << ":" << ntohs(_svr.sin_port) << "] " << std::endl
<< msg_buf;
}
private:
int _sockfd; // 套接字文件描述符
std::string _svr_ip; // 服务器ip
uint16_t _svr_port; // 服务器port
struct sockaddr_in _svr; // 服务器地址信息
};
⭕tips:
服务器是稳定的,长期运行的,因此其端口号需要在启动时指定且运行时一直保持不变,才能让客户端能准确地找到服务器。
客户端是动态的,随时可能退出与重连,而且可能会有多个客户端同时存在。因此客户端的端口号不应该由用户指定,而是OS动态分配,避免端口冲突,提高并发性能。OS在客户端第一次调用Socket Api完成地址的绑定工作。客户端分配的端口号是临时的,在连接关闭后释放。
实现客户端能模拟类似微信群聊的功能。在服务器中设置一个环形队列
cirQueue
,并设置两个线程,一个用于接收用户消息,一个用于广播用户消息(即向每位用户发送head消息)。为了满足向用户广播消息的需求,服务器里还需储存当前在线用户的信息。
// GroupChatServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "err.hpp"
#include "cirQueue.hpp"
#include "Thread.hpp"
const int BUF_SIZE = 1024;
using serv_func_t = std::function<std::string(const std::string &str)>;
// 接收客户端的信息,并传回客户端
class UdpServer
{
public:
UdpServer(uint16_t port, serv_func_t service)
: _port(port), _service(service)
{
// 调用之前写的Thread组件,两个线程,一个负责收消息,一个负责广播数据
_p = Thread(1, RecvThreadRoutine, this);
_c = Thread(2, BoardcastThreadRoutine, this);
}
static void *RecvThreadRoutine(void *args)
{
UdpServer *ts = static_cast<UdpServer *>(args);
while (true)
{
ts->Recv();
}
return nullptr;
}
static void *BoardcastThreadRoutine(void *args)
{
UdpServer *ts = static_cast<UdpServer *>(args);
while (true)
{
ts->Boardcast();
}
return nullptr;
}
void Initial()
{
// 1. 创建socket套接字(协议族,声明是哪种通信、哪种协议)
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
// 创建套接字失败
std::cerr << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
_sockfd = sockfd;
std::cout << "socket creat success: " << _sockfd << std::endl;
// 2. 绑定IP地址和端口号(地址族,标定网络通信地址)
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
{
// 绑定失败
std::cerr << strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "socket bind success: "
<< "port->" << _port << std::endl;
}
// 服务器启动,即两个线程开始运行,主线程等待即可
void Start()
{
_p.run();
_c.run();
_p.join();
_c.join();
}
void Recv()
{
// 1.1 创建接收数据的缓冲区
char buf[BUF_SIZE];
memset(buf, 0, sizeof(buf));
// 1.2 创建套接字
struct sockaddr_in client;
bzero(&client, sizeof(client));
socklen_t len = sizeof(client);
// 1.3 从sock中接收客户端数据
ssize_t n = recvfrom(_sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&client, &len);
if (n < 0)
{
std::cerr << "recv error: " << strerror(errno) << std::endl;
exit(RECV_ERR);
}
buf[n] = '\0';
// 1.4 打包用户信息,并添加新用户
std::string client_info = inet_ntoa(client.sin_addr);
client_info += '-';
client_info += std::to_string(ntohs(client.sin_port));
AddOnlineUser(client_info, client);
// 1.5 将消息投放到公共聊天窗口(即环形缓冲区)中
std::string message = "[" + client_info + "] " + buf;
_messages.push(message);
}
void Boardcast()
{
// 1. 取出环形缓冲区的头部数据,这是我们这次要广播的消息
std::string message;
_messages.pop(&message);
// 2. 发给每一个在线用户
// 2.1 先加锁拷贝一份在线用户信息副本(公有->私有)
list<struct sockaddr_in> sins;
{
std::unique_lock<std::mutex> lck(_mtx);
for (auto &usr : _online_users)
{
std::cout << "send to " << usr.first << ": " << message << std::endl;
sins.push_back(usr.second);
}
}
// 2.2 再用线程私有的副本进行网络IO将信息传给客户端
for (auto &sin : sins)
{
ssize_t n = sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&sin, sizeof(sin));
if (n < 0)
{
std::cerr << "send error: " << strerror(errno) << std::endl;
exit(SEND_ERR);
}
}
}
private:
void AddOnlineUser(const std::string &user, struct sockaddr_in &sin)
{
std::unique_lock<std::mutex> lck(_mtx);
// if user is a new client, add it into online_users, else do nothing
size_t size = _online_users.size();
_online_users[user] = sin;
if (_online_users.size() > size)
std::cout << "新用户加入: " << user << std::endl;
}
private:
int _sockfd; // 套接字文件fd
uint16_t _port; // 服务器端口号
serv_func_t _service; // 业务处理函数
cirQueue<std::string> _messages; // 存储用户消息的环形队列
std::unordered_map<std::string, struct sockaddr_in> _online_users; // 在线用户信息表(用户ip和port)
Thread _p; // recv线程(生产者)
Thread _c; // boardcast线程(消费者)
std::mutex _mtx; // 保护_online_users的锁
};
//client.cc(无封装版本)
#include
#include
#include
#include
#include
#include
#include
#include "Thread.hpp"
#include "err.hpp"
const int BUF_SIZE = 1024;
void Usage()
{
std::cout << "Please enter the correct format: " << std::endl
<< " ./client [server's ip] [server's port]" << std::endl;
}
struct ThreadData
{
ThreadData(int sockfd, struct sockaddr_in *psvr) : _sockfd(sockfd), _psvr(psvr)
{
}
int _sockfd;
struct sockaddr_in *_psvr;
};
void *SendThreadRountine(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
while (true)
{
// 1. 用户输入消息
std::string message;
std::cout << "Please Enter# ";
std::getline(std::cin, message);
// 2. 发送消息到服务端
ssize_t n = sendto(td->_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)td->_psvr, sizeof(*td->_psvr));
if (n < 0)
{
std::cerr << "send error: " << strerror(errno) << std::endl;
exit(SEND_ERR);
}
}
delete td;
return nullptr;
}
void *RecvThreadRountine(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
while (true)
{
// 接收其它客户端的消息(来自服务端)
char buf[BUF_SIZE];
memset(buf, 0, sizeof(buf));
struct sockaddr_in tmp;
bzero(&tmp, sizeof(tmp));
socklen_t len = sizeof(tmp);
ssize_t n = recvfrom(td->_sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&tmp, &len);
if (n < 0)
{
std::cerr << "recv error: " << strerror(errno) << std::endl;
exit(RECV_ERR);
}
buf[n] = '\0';
// 群聊信息打印到2号文件描述符上,方便重定向观察输出结果
std::cerr << buf << std::endl;
}
delete td;
return nullptr;
}
// ./client [server's ip] [server's port]
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage();
exit(USAGE_ERR);
}
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
// IP和端口号由OS自动分配绑定
// 2. 获取服务端ip和port
std::string server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
// 指明服务端的地址族(ip and port)
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
// 一个线程负责让用户输入并发送消息,一个线程负责接收群聊服务器的消息
Thread send_thread(1, SendThreadRountine, new ThreadData(sockfd, &server));
Thread recv_thread(2, RecvThreadRountine, new ThreadData(sockfd, &server));
send_thread.run();
recv_thread.run();
send_thread.join();
recv_thread.join();
return 0;
}
⭕呈现效果如下
TCP协议规定面向连接的网络通信,传输数据面向字节流。TCP服务器除了完成socket
创建套接字和bind
绑定本机地址外,还需要做如下两件事:
设置套接字为监听状态,等待客户端的连接请求
#include
#include
int listen(int sockfd, int backlog);
参数:
sockfd
:服务器的套接字文件描述符backlog
:用于指定等待连接队列的最大长度返回值:
成功返回0,失败返回-1,错误码errno被设置
接收客户端的连接请求
#include
#include
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
sockfd
:服务器的套接字文件描述符addr
:用于存储接收到的客户端的地址信息- 一个指向
socklen_t
类型的指针,用于指定src_addr
缓冲区的长度。在调用accept
之前,你需要将addrlen
设置为addr
缓冲区的大小。返回值:
返回一个套接字文件描述符,该描述符面向已接收到的客户端,服务器通过该描述符与此客户端进行通信。也就是说,TCP服务器为每一个已连接的客户端创建一个专属的套接字。
服务器等待客户端的连接请求,客户端调用connect
函数向指定的服务器发送连接请求。
#include
#include
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd
:客户端的套接字文件描述符addr
:指定的服务器地址信息addrlen
:addr指向空间的长度返回值:
成功返回0,失败返回-1,错误码errno被设置
TcpServer的成员变量
private:
int _sockfd; // 服务器的套接字文件描述符
uint16_t _svr_port; // 服务器端口号
func_t _service; // 业务处理函数
TcpServer的初始化
void Initial()
{
// 1.创建套接字,TCP的传输数据类型是SOCK_STREAM
if ((_sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
exit(SOCKET_ERR);
}
// 2.绑定服务器本地地址
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_svr_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
{
exit(BIND_ERR);
}
// 3.设置服务器套接字为监听状态
if (listen(_sockfd, backlog) < 0)
{
exit(LISTEN_ERR);
}
}
TcpServer的启动工作
// 3.1 多进程版本
void Start()
{
while (true)
{
struct sockaddr_in client;
bzero(&client, sizeof(client));
socklen_t len = sizeof(client);
// 1.接收请求连接的客户端
int cfd = accept(_sockfd, (struct sockaddr *)&client, &len);
if (cfd < 0)
{
// no client connect, continue try to accept
continue;
}
std::string client_info = std::string(inet_ntoa(client.sin_addr)) + "-" + std::to_string(ntohs(client.sin_port));
std::cout << "新用户已接入" << client_info << " cfd: " << cfd << std::endl;
// 2. 收发数据的工作交给子进程做,父进程只负责监听与接收客户端
// 2.1 父进程不关心子进程的退出结果,不等待子进程
signal(SIGCHLD, SIG_IGN);
// 2.2 创建子进程
pid_t id = fork();
assert(id >= 0);
if (id == 0)
{
while (true)
{
std::string respond = Recv(cfd, client_info);
Send(cfd, respond);
}
}
// 2.3 父进程不再需要维护当前客户端的sockfd,直接close,并继续accept其它客户端,这样做可减少文件描述符的消耗
close(cfd);
}
}
// 3.2 多线程版本
void Start()
{
std::cout << "server start!" << std::endl;
while (true)
{
struct sockaddr_in client;
bzero(&client, sizeof(client));
socklen_t len = sizeof(client);
// 1.接受监听的客户端
int cfd = accept(_sockfd, (struct sockaddr *)&client, &len);
if (cfd < 0)
{
// no client connect, continue try to accept
continue;
}
std::string client_info = std::string(inet_ntoa(client.sin_addr)) + "-" + std::to_string(ntohs(client.sin_port));
std::cout << "### 新用户已接入" << client_info << " cfd: " << cfd << " ###" << std::endl;
// 2. 收发数据的工作交给新线程做,主线程只负责监听与接收客户端
// 一个客户端创建一个服务线程
pthread_t pid;
pthread_create(&pid, nullptr, threadRoutine, new ThreadData(cfd, client_info, this));
}
}
struct ThreadData
{
ThreadData() = default;
ThreadData(int cfd, std::string cinfo, TcpServer *ts)
: _cfd(cfd), _cinfo(cinfo), _ts(ts)
{
}
int _cfd;
std::string _cinfo;
TcpServer *_ts;
};
static void *threadRoutine(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
pthread_detach(pthread_self()); // 分离线程,这样主线程无需等待该线程,提高服务器效率
while (true)
{
std::string respond = td->_ts->Recv(td->_cfd, td->_cinfo);
td->_ts->Send(td->_cfd, respond);
}
}
⭕注意:多进程版本,因为子进程拷贝了父进程的文件描述符表,每个进程拥有一张独立的文件描述符表,所以父进程可以关闭已经交给子进程的客户端sockfd。而多线程版本,各线程共享同一张文件描述符表,主线程在客户端退出之前不能关闭客户端sockfd,否则会导致工作线程找不到对接的客户端
TcpServer的Recv函数(接收客户端数据)
由于TCP是面向字节流传输的,所以TcpServer的数据传输本质上就是对文件的读写,调用
read
与write
,操作的是客户端套接字文件描述符。与管道文件的同步机制类似,如果套接字对接的客户端退出,服务器read
的返回值就是0。
std::string Recv(int cfd, const std::string &ci)
{
// read读取客户端数据
char buf[BUF_SIZE];
memset(buf, 0, sizeof(buf));
std::string respond;
ssize_t n = read(cfd, buf, sizeof(buf) - 1);
if (n < 0)
{
// 读取失败,当前子执行流退出
std::cerr << "read from client fail: " << strerror(errno) << std::endl;
// exit(READ_ERR); // 多进程版
pthread_exit(nullptr);
}
else if (n == 0)
{
// 客户端已退出
close(cfd);
// 多进程版可以不close,子进程exit也就回收了,对父进程没有影响
// 多线程版必须close,防止文件fd泄漏
// exit(0); // 多进程版
pthread_exit(nullptr);
}
else
{
// 读取成功,将数据业务处理后返回
buf[n] = '\0';
return _service(buf);
}
}
TcpServer的Send函数(发送数据到客户端)
void Send(int cfd, std::string respond)
{
// 向客户端发回数据
ssize_t n = write(cfd, respond.c_str(), respond.size());
if (n < 0)
{
// write fail
std::cerr << "write to client fail: " << strerror(errno) << std::endl;
exit(WRITE_ERR);
}
}
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "err.hpp"
class TcpClient
{
static const size_t BUF_SIZE = 1024;
public:
TcpClient(uint16_t svr_port, std::string svr_ip)
: _svr_port(svr_port), _svr_ip(svr_ip)
{
bzero(&_svr, sizeof(_svr));
}
void Initial()
{
// 1.创建套接字
if ((_sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
std::cerr << "socket create fail: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
// 由OS自动绑定ip和port
// 2.指定客户端地址族
_svr.sin_family = AF_INET;
_svr.sin_port = htons(_svr_port);
_svr.sin_addr.s_addr = inet_addr(_svr_ip.c_str());
}
void Start()
{
// 1. 向服务器发送connect请求
int times = 5;
while (times > 0 && connect(_sockfd, (struct sockaddr *)&_svr, sizeof(_svr)) < 0)
{
std::cout << "正在尝试重新连接服务器..." << times-- << std::endl;
sleep(1);
}
if (times == 0)
{
std::cout << "连接失败!" << std::endl;
exit(CONNECT_ERR);
}
// connect success
std::cout << "连接成功!" << std::endl;
// 2. 开始工作
while (true)
{
// 2.1 向服务端发送消息
std::string message;
std::cout << "ENTER:> ";
std::getline(std::cin, message);
if (message == "quit")
{
close(_sockfd);
return;
}
ssize_t n = write(_sockfd, message.c_str(), message.size());
if (n < 0)
{
// write fail
std::cerr << "write to client fail: " << strerror(errno) << std::endl;
exit(WRITE_ERR);
}
// 2.2 接收服务端返回的消息
char buf[BUF_SIZE];
memset(buf, 0, sizeof(buf));
n = read(_sockfd, buf, sizeof(buf) - 1);
if (n < 0)
{
// read fail
std::cerr << "read from server fail: " << strerror(errno) << std::endl;
exit(READ_ERR);
}
else if (n == 0)
{
// 同样的,服务器退出,客户端read返回值为0
std::cout << "server quit" << std::endl;
close(_sockfd);
break;
}
else
{
buf[n] = '\0';
std::cout << "server sent to: " << buf << std::endl;
}
}
}
private:
int _sockfd; // 客户端套接字文件描述符
uint16_t _svr_port; // 服务器端口号
std::string _svr_ip; // 服务器IP
struct sockaddr_in _svr; // 服务器地址信息
};
TcpServer的多进程版,频繁创建子进程,开销大,效率低。多线程版相较于多进程版提高了效率,减少创建子进程的效率损耗和资源浪费,但频繁创建线程依然开销不低。因此可以引入线程池,减少频繁创建和销毁线程的开销,提高并发效率。
// 引入线程池的版本(仅展示与其它版本不同之处)
class Task
{
using func_t = function<void(int cfd, const std::string &client_info)>;
public:
Task() = default;
Task(int cfd, const std::string &client_info, func_t cb)
: _cfd(cfd), _client_info(client_info), _cb(cb)
{
}
// 线程池中调用Task::operator()执行任务
void operator()()
{
_cb(_cfd, _client_info);
}
private:
int _cfd; // 客户端套接字文件描述符
std::string _client_info; // 客户端信息
func_t _cb; // 回调函数
};
void Start()
{
while (true)
{
struct sockaddr_in client;
bzero(&client, sizeof(client));
socklen_t len = sizeof(client);
// 1.接受监听的客户端
int cfd = accept(_sockfd, (struct sockaddr *)&client, &len);
if (cfd < 0)
{
// no client connect, continue try to accept
LogMessage(WARNING, "accept fail: %s\n", strerror(errno));
sleep(1);
continue;
}
std::string client_info = std::string(inet_ntoa(client.sin_addr)) + "-" + std::to_string(ntohs(client.sin_port));
// 2.创建一个与客户端交互的任务t,并交给线程池
Task t(cfd, client_info,
std::bind(&TcpServer::ServerThreadRountine, this, std::placeholders::_1, std::placeholders::_2));
// 回调函数需要绑定this指针,否则无法调用TcpServer::ServerThreadRountine
threadPool<Task>::get_instance()->pushTask(t);
}
}
void ServerThreadRountine(int cfd, const std::string &client_info)
{
bool quit = false; // 判断客户端是否已退出的标志
std::string respond; // 输出型参数
while (true)
{
Recv(cfd, client_info, respond, quit); // Recv内部检查客户端是否已退出
if (quit)
{
break;
}
Send(cfd, respond);
}
}
void Recv(int cfd, const std::string &ci, std::string &respond, bool &quit)
{
char buf[BUF_SIZE];
memset(buf, 0, sizeof(buf));
ssize_t n = read(cfd, buf, sizeof(buf) - 1);
if (n < 0)
{
// read fail
quit = true;
return;
}
else if (n == 0)
{
close(cfd);
quit = true;
return;
}
else
{
buf[n] = '\0';
respond = _service(buf);
}
}
void Send(int cfd, const std::string &respond)
{
ssize_t n = write(cfd, respond.c_str(), respond.size());
if (n < 0)
{
// write fail
std::cerr << "write to client fail: " << strerror(errno) << std::endl;
}
}
服务器需要有日志系统,方便开发者对于服务器的维护工作。
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
static const char *filename = "server.log";
// 日志等级
enum loglevel_t
{
TRACE,
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
static std::map<loglevel_t, std::string> ltos = {{TRACE, "TRACE"}, {DEBUG, "DEBUG"}, {INFO, "INFO"}, {WARNING, "WARNING"}, {ERROR, "ERROR"}, {FATAL, "FATAL"}};
// 日志格式:log = title(log level, time, pid) + body
void LogMessage(loglevel_t lv, const char *format, ...)
{
// 1. title
time_t t = time(nullptr);
struct tm *tp = localtime(&t);
// 2. time = y-m-d h:m:s
char timestr[64];
memset(timestr, 0, sizeof(timestr));
snprintf(timestr, sizeof(timestr), "%d-%d-%d %d:%d:%d", tp->tm_year + 1900, tp->tm_mon + 1, tp->tm_mday, tp->tm_hour, tp->tm_min, tp->tm_sec);
std::string logtitle = ltos[lv] + " " + timestr + " " + std::to_string(getpid());
// 3. body
va_list ap;
char logbody[128];
memset(logbody, 0, sizeof(logbody));
va_start(ap, format);
vsnprintf(logbody, sizeof(logbody), format, ap);
va_end(ap);
// 4. 输出
// 输出到终端
// combine and output
// printf("[%s] %s", logtitle.c_str(), logbody);
// 保存到文件
FILE *fp = fopen(filename, "a");
fprintf(fp, "[%s] %s", logtitle.c_str(), logbody);
fclose(fp);
}
about C语言函数的可变参数
函数的传输列表中,
...
表示可变参数。拿上述日志系统中void LogMessage(loglevel_t lv, const char *format, ...)
进行分析。
C语言提供一组宏函数来对可变参数进行操作,包含头文件#include
参考文章:
va_list
:指向可变参数首地址的指针类型
void va_start(va_list ap, last)
:以固定参数last
(参数列表...
前的最后一个参数)的地址为起点确定变参的内存起始地址,获取第一个参数的首地址赋值给ap
。
type va_arg(va_list ap, type)
:获取下一个参数的地址(跳转字节数sizeof type
)
void va_end(va_list ap)
: 将ap指针置空
而有了格式控制字符串format
,可以更好地使用可变参数
int vsnprintf(char *str, size_t size, const char *format, va_list ap)
函数解释:
ap
指向可变参数列表的首地址,根据格式控制format
,将长度为size
的字符串拷贝到str
中。
先介绍一个概念,Linux中的会话:
会话(Session)是一个用于管理和组织进程的概念。会话是一个抽象层级,用于将相关进程分组在一起,以便它们可以协同工作并共享某些属性。以下是有关Linux会话的详细解释。
- 一个会话可以与一个控制终端相关联。 我们平时用shell时启动的一个终端窗口,实际上就关联了某个独立的会话。
- 每个会话都有一个唯一的标识符,称为SID(Session ID),它是一个整数值。这个SID与会话中第一个创建的进程(也称话首进程)的PID相同。
- 一个会话中可以有一个或多个进程组。 进程组是一组相关进程的集合,它们通常用于完成同一项任务,一个进程组中的进程都在同一个会话中。进程组有一个唯一的标识符PGID,进程组的PGID=进程组组长的PID。
- 一个会话至多有一个前台进程,可以有多个后台进程。
我们在shell以用户身份登录时,Linux操作系统中执行了哪些动作。
回到服务器的层面上。服务器一般都是一直在运行,不分昼夜,就如我们三更半夜也能刷b站、发微信。而我们刚刚写的服务器,启动在与某个终端相关联的会话中,一旦该会话关闭,服务器也随之退出,客户端将无法找到服务器。那么,我们需要将服务器与终端分离,创建一个专属于服务器的、不依赖于某个终端的会话,使之一直在系统中运行,这个过程称为服务器的守护进程化(Daemon)。
⭕核心的系统调用接口:setsid
#include
pid_t setsid(void);
功能:创建一个新会话,并将调用进程设为新会话的话首进程。新会话不与任何终端产生关联,
参数:无
返回值:成功返回新会话的SID,失败返回
(pid_t)-1
,错误码被设置。
需要注意的是,调用setsid
的进程不能是某个进程组的组长进程,否则创建新会话失败。
// daemon.hpp
#include
#include
#include
#include
#include
#include
void Daemon()
{
// 1.创建子进程,父进程退出(保证服务器非组长进程)
if (fork() > 0)
exit(0);
// 2.子进程创建新会话
pid_t id = setsid();
// 3.修改工作路径(可选做)
// 4. 处理文件描述符0/1/2,因为守护进程没有关联终端
// 方法1:重定向文件描述符0/1/2到/dev/null(因为守护进程没有关联终端)
int fd = open("/dev/null", O_RDWR);
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
// 方法2:直接close
// close(0);
// close(1);
// close(2);
}
tcp服务器与客户端完成代码已push到本人gitee,需要的小伙伴可以自取~
「tcp服务器与客户端、日志系统、守护进程代码」
Ending…