前提
- 在Linux中,套接字(socket)是用于网络通信的主要接口。(套接字(Socket)是包括 IP 地址和端口号的。在网络通信中,套接字用于标识一个网络连接的两个端点,其中包括了通信所需的地址和端口信息)
- 套接字本身可以支持多种协议,包括UDP和TCP。通过指定不同的参数来创建套接字,可以选择使用UDP或TCP协议。以下是如何在Linux中区分和创建UDP和TCP套接字的方法。
创建tcp套接字:
sockfd = socket(AF_INET, SOCK_STREAM, 0);
创建udp套接字:
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
- AF_INET 是地址族(Address Family)的一种,表示IPv4协议
- SOCK_STREAM(TCP连接)和SOCK_DGRAM(UDP连接)是套接字类型
- 0 表示默认协议
tip:在linux下使用socket,可以通过man socket指令查看socket文件的使用说明
文件描述符:文件描述符(File Descriptor)是操作系统提供的一个抽象概念,用于标识打开文件或者其他 I/O 设备的引用。在 Unix 和类 Unix 系统中(如 Linux),每个进程都有一个文件描述符表,用于管理它所打开的文件和设备。文件描述符是一个非负整数,通常是
int
类型。{文件描述符:数据结构}======》{int : 数据结构}
万物皆可键值对
目标:实现多个客户端和服务端通信 服务端是一直在等待接收消息,客户端是一直在等待发送消息 | |
服务端需要实现几个功能: | 客户端实现需要几个功能: |
1 能接收到未知客户端的连接 | 1 能够向已知IP地址(服务端发送信息) |
2 接收到未知客户端的连接后,读取发送过来的信息 | 2 能够接收到已知服务端信息 |
3 能够向1中接收到的客户端的联系写入回应消息 |
代码1:有多进程的控制,但需要频繁的创建和销毁进程,资源开销大。
1.1 服务端代码:
创建一个服务端对象,实现以下功能
- 创建一个TCP套接字。
- 绑定套接字到指定的端口。
- 将套接字设置为监听模式,准备监听新连接。
- 接受一个客户端连接,并返回一个新的套接字文件描述符用于与客户端通信。
- 处理客户端的请求,接收消息并发送响应
- 返回服务器套接字的文件描述符
在网络编程中,sockfd
是一个常见的缩写,通常用来表示一个套接字(socket file descriptor)的文件描述符。文件描述符是一个用于标识和操作文件或其他I/O资源的整数(int)。
{sockfd:socket}
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
#include<thread>
class Socket {
public:
Socket() : sockfd_(-1) {}
~Socket() { close(sockfd_); }
void create();
void bind(int port);
void listen();
int accept();
void handleClient(int clientSocket);
int getSockfd(){return sockfd_;}
private:
int sockfd_;
};
1.1.1 创建一个TCP套接字socket,返回一个套接字socket的文件描述符sockfd_(int);
void Socket::create() {
sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd_ < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
}
1.1.1 将本地ip地址和一个端口绑定,用来监听连接
- 下面是linux中的套接字结构,地址簇,端口号,ip地址
- sockaddr_in结构可以通过man 7 ip指令查看
通过套接字的文件描述符找到套接字,并且绑定端口号
tip: ::bind是全局作用域下的bind,void Socket::bind是Socket的类函数bind;两者加以区分。
void Socket::bind(int port) {//8080
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(port);
//bind 函数将套接字与指定的IP地址和端口绑定,使得这个套接字可以接收来自该地址和端口的网络通信。
if (::bind(sockfd_, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
}
监听sockfd对应的服务端套接字地址上是否有连接,如果 listen()
函数返回非零值,表示监听失败,通常是由于套接字设置错误或系统资源不足导致的。在这种情况下,会打印错误信息并退出程序
::listen
是 C++ 中调用全局作用域中的listen
函数的方式,void Socket::listen()是Socket的类函数listen函数sockfd_
是之前创建的套接字描述符,用于标识要监听的套接字。1024
是指定等待连接队列的最大长度,即可以排队等待调用accept()
的客户端连接请求的数量。
void Socket::listen() {
if (0 != ::listen(sockfd_, 1024)) {
perror("listen failed");
exit(EXIT_FAILURE);
}
std::cout << "Listening on port 8080..." << std::endl;
}
接收一个客户端连接
accept 函数用于从监听套接字中提取一个连接请求,并创建一个新的套接字以及对应的套接字文件描述符,用于与该客户端进行通信。如果返回的客户端套接字文字描述符小于0,则判定接收失败
int Socket::accept() {
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
//accept 函数用于从监听套接字中提取一个连接请求,并创建一个新的套接字,用于与该客户端进行通信。
int client_fd = ::accept(sockfd_, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept failed");
exit(EXIT_FAILURE);
}
std::cout << "Accepted connection from " << inet_ntoa(client_addr.sin_addr) << std::endl;
return client_fd;
}
处理客户端连接
预置一个buffer缓冲区,用来接收客户端发送到服务端内核的数据
通过read读,将从内核的数据读到buffer缓冲区,这里应该是阻塞IO模式()
在服务端界面输出客户端输入的内容
读取服务端输入,通过write函数和客户端文件描述符发送到客户端
void Socket::handleClient(int clientSocket) {
char buffer[1024];
while (true) {
// 读取客户端发送的数据
ssize_t bytesRead = read(clientSocket, buffer, sizeof(buffer) - 1);
if (bytesRead <= 0) {
std::cout << "Client disconnected." << std::endl;
break;
}
buffer[bytesRead] = '\0';
std::cout << "Client: " << buffer << std::endl;
// 从标准输入读取数据并发送到客户端
std::string response;
std::cout << "Server: ";
std::getline(std::cin, response);
write(clientSocket, response.c_str(), response.size());
}
}
main函数
- 实例化一个服务端程序
- 创造服务端的套接字
- 绑定8080端口
- 开启监听模式
- 开启事件循环
当
fork()
在父进程中返回一个大于0的值,表示父进程中的执行路径。当
fork()
在子进程中返回0,表示子进程中的执行路径。当
fork()
失败时返回负数,在此示例中没有特别处理,但实际应用中应添加错误处理逻辑。
int main() {
Socket serverSocket;
serverSocket.create();
serverSocket.bind(8080);
serverSocket.listen();
while (true) {
int clientSocket = serverSocket.accept();
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
close(clientSocket);
continue;
} else if (pid > 0) {
close(clientSocket);
} else {
close(serverSocket.getSockfd());
serverSocket.handleClient(clientSocket);
close(clientSocket);
exit(0);
}
}
return 0;
}
1.2 客户端代码
创建一个套接字,并且返回自己对应的套接字文件描述符
连接服务端
向服务端发送数据,读取服务端发送的数据
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
class Client {
public:
Client() : sockfd_(-1) {}
~Client() { close(sockfd_); }
void create();
void connect(const char* server_ip, int port);
void communicate();
private:
int sockfd_;
};
创建一个套接字,并返回对应的文件描述符
void Client::create() {
sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd_ < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
}
返回的套接字文件描述符是用来绑定传入的服务端地址和端口号的
void Client::connect(const char* server_ip, int port) {
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(port);
if (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {
perror("inet_pton failed");
exit(EXIT_FAILURE);
}
if (::connect(sockfd_, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect failed");
exit(EXIT_FAILURE);
}
}
与服务端通信
void Client::communicate() {
char buffer[1024];
while (true) {
// 从标准输入读取数据并发送到服务器
std::string message;
std::cout << "Client2: ";
std::getline(std::cin, message);
write(sockfd_, message.c_str(), message.size());
// 读取服务器发送的数据
ssize_t bytesRead = read(sockfd_, buffer, sizeof(buffer) - 1);
if (bytesRead <= 0) {
std::cout << "Server disconnected." << std::endl;
break;
}
buffer[bytesRead] = '\0';
std::cout << "Server: " << buffer << std::endl;
}
}
编译程序
g++ -o server server.cpp //服务端
g++ -o client client.cpp //客户端
客户端1运行界面:
客户端2运行界面:
服务端运行界面:
代码2 在代码1的基础上创建了线程池,预先创建一定的线程池连接,避免了频繁的开启,关闭线程
线程池(ThreadPool)和连接池(Connection Pool)是在软件工程中常见的两种池化技术,用于提高系统性能和资源利用率。它们有不同的作用和应用场景:
线程池(ThreadPool):
- 作用: 线程池管理和复用线程,以便并发处理多个任务,避免频繁创建和销毁线程带来的开销。
- 原理: 在启动时预先创建一定数量的线程,这些线程在整个程序运行过程中被重复使用。任务提交给线程池后,空闲线程会执行任务并在完成后返回池中等待新任务。如果没有空闲线程,则任务会排队等待。
- 优势: 减少线程创建和销毁的开销,提高任务处理的效率和响应速度,管理和限制系统中的并发线程数量。
连接池(Connection Pool):
- 作用: 连接池管理和复用数据库或其他服务的连接,以避免频繁创建和断开连接的开销。
- 原理: 在应用程序启动或需求增加时,预先建立一定数量的连接。应用程序需要访问数据库时,从连接池中获取一个空闲连接,使用完毕后归还到连接池而不是关闭连接。
- 优势: 减少连接建立和断开的开销,降低数据库服务器的负载,提高应用程序对数据库的访问效率和响应速度。
- 线程池(vector动态数组)大小的初始化设定一个默认值
- 要是传入线程池的大小,就按照传入数量设计线程池的大小,
- 要是没有传入线程池的大小,就按照默认值设置线程池的大小
- 如果线程池里没有空闲的线程,就在线程池里插入一个线程。
- 要是线程池里很多线程空闲,就删除线程池中的线程
2.1 线程池代码
首先确认以下成员变量
- 一个线程数组:std::vector<std::thread> workers;
- 一个任务队列:std::queue<std::function<void()>> tasks;
- 一把互斥锁:std::mutex queue_mutex;
- 一个条件变量:std::condition_variable condition;
- 一个原子操作:std::atomic<bool> stop;
- 一个默认线程池大小:size_t defaultThreads;
构造函数主要实现一个功能,workers.emplace_back(thread(function));
- 里面插入的是一个lambda函数[]{};
this
是指当前线程池对象的实例。这种方式允许你在 lambda 表达式中访问线程池对象的成员变量和成员函数,而不必显式地传递线程池对象的引用或指针。 - 当前线程在
condition
条件变量上等待,直到满足[this] { return stop || !tasks.empty(); }
的条件。 - 如果队列不为空,取出当前队列最前面一个赋值给task,删除任务队列中最前面的一个
- 执行task()
结合下面的enqueue函数,实现以下操作
构造函数中初始化时
- 锁定互斥锁,保护共享资源tasks的任务队列,确保同时只能有一个线程进行访问操作,确保在同一时间只有一个线程可以访问共享资源,从而避免多个线程同时对共享资源进行读写造成的数据竞争问题。
- 等待stop为true,或者tasks任务表非空,重新获取互斥锁
- 释放互斥锁,(由于初始化时stop为false),退出任务循环
在下面的enqueue函数中
- 锁定互斥锁,保护共享资源tasks的任务队列,确保同时只有一个线程进行访问操作
- 将任务函数放入到任务列表中
- 释放互斥锁
- 唤醒等待在条件变量上的一个线程,也就是上面的线程池中的一个线程
回到唤醒的线程中,执行里面的函数操作
- 锁定互斥锁,保护共享资源tasks的任务队列
- 此时队列不为空,执行下一步操作
- 如果stop为false并且队列为空,退出操作
- 如果队列不为空,取出任务队列队首函数,删除队列中函数,并且执行函数操作,直到任务队列为空,退出循环。
#include <iostream>
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <chrono>
#include <atomic>
class ThreadPool {
public:
ThreadPool(size_t numThreads) : stop(false), defaultThreads(numThreads) {
for (size_t i = 0; i < numThreads; ++i) {
workers.emplace_back(
[this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queue_mutex);
condition.wait(lock, [this] { return stop || !tasks.empty(); });
if (stop && tasks.empty())
return;
if (!tasks.empty()) {
task = std::move(tasks.front());
tasks.pop();
} else {
continue;
}
}
task();
}
}
);
}
}
将传入的函数操作放入到任务队列中,唤醒一个线程(执行函数操作),并且查看是否要调整线程池大小,小了就调大,空闲的多了,就删除
void enqueue(std::function<void()> f) {
{
std::unique_lock<std::mutex> lock(queue_mutex);
tasks.emplace(std::move(f));
}
condition.notify_one();
adjustThreadPoolSize();
}
线程池的释放
- 锁定互斥锁,确保同一时刻只有一个线程对stop进行修改,将stop置为true,释放互斥锁
- 唤醒线程池中所有线程
- 等待所有线程上的任务执行完毕(如果不是worker.join()的话,那么主线程执行完毕后,线程池上的线程没有执行完毕,也退出程序了,程序代码有一个默认的主线程在执行)
如果主线程执行完毕而其他线程尚未执行完毕并且没有调用
join()
等待它们,程序可能会立即退出,从而导致未完成的线程操作。这通常是需要避免的,因为未完成的线程操作可能会导致资源泄漏或不一致的状态。使用
join()
方法等待所有线程完成是一种良好的做法,它确保程序在退出前,所有线程都已经执行完毕并释放了它们的资源,从而保证程序的正确性和稳定性。
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for (std::thread &worker : workers)
worker.join();
}
private:
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queue_mutex;
std::condition_variable condition;
std::atomic<bool> stop;
size_t defaultThreads;
动态调整线程池的大小
如果任务队列大小大于线程池的大小或者线程池小于默认线程池的大小,就增加线程池的大小
如果任务队列为空,并且线程池的大小大于默认线程池的大小,就减小线程池的大小
void adjustThreadPoolSize() {
size_t currentThreads = workers.size();
if (tasks.size() > currentThreads && currentThreads < defaultThreads) {
addThread();
}
if (tasks.empty() && currentThreads > defaultThreads) {
removeThread();
}
}
扩大线程池的大小
void addThread() {
workers.emplace_back(
[this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queue_mutex);
condition.wait(lock, [this] { return stop || !tasks.empty(); });
if (stop && tasks.empty())
return;
if (!tasks.empty()) {
task = std::move(tasks.front());
tasks.pop();
} else {
continue;
}
}
task();
}
}
);
}
删除线程池中的一个线程:等待最后一个线程结束任务,然后删除
void removeThread() {
workers.back().join();//等待线程池中的最后一个线程完成任务
workers.pop_back(); //删除数组中的最后一个线程
}
2.2 服务端代码
多了一个线程池成员,以及对线程池的初始化,这里传入的线程池初始化就设置为线程池的默认大小
#include“threadpool.h”
class server{
public:
...
Socket() : sockfd_(-1),threadpool(3){}
void acceptloop();
...
private:
...
ThreadPool threadpool;
}
acceptloop函数
void Socket::acceptloop() {
while (true) {
struct sockaddr_in clientAddr;
socklen_t clientAddrLen = sizeof(clientAddr);
int clientSocket = ::accept(sockfd_, (struct sockaddr *)&clientAddr, &clientAddrLen);
if (clientSocket == -1) {
perror("Accept failed");
close(sockfd_);
exit(EXIT_FAILURE);
}
// 将函数放入到线程池的任务队列中
threadpool.enqueue([this, clientSocket] {
handleClient(clientSocket);
});
}
}
int main() {
Socket serverSocket;
serverSocket.create();
serverSocket.bind(8080);
serverSocket.listen();
serverSocket.acceptloop();
return 0;
}
代码3 在代码2的基础上,实现了一点业务
3.1 服务端代码修改
简单的在服务端通过构建一个unordered_map表,实现客户端输出中文,服务端输出对应英文的功能(这里的中文和对应英文是预先对应好的)
#include<unordered_map>
class server{
public:
...
private:
std::unordered_map<std::string,std::string> dictionary={{"苹果","apple"},{"橘子","orange"},{"梨子","pear"}};
}
服务端handleclient函数修改
void Socket::handleClient(int clientSocket) {
...
// 从标准输入读取数据并发送到客户端
std::string response;
if(dictionary.find(buffer)!=dictionary.end()){response=dictionary[buffer];}
else response="There is no English word for this Chinese word in the database!";
...
}
}
结果展示:
代码4 在代码3的基础上,通过将unordered_map表代替为mysql数据库
4.1 mysql数据表创建
在mysql中的dictionary数据库中创建了一个dictionary数据表
表中插入的数据
编译:因为使用了mysql 所以编译时要使用动态链接到mysql数据库上
g++ server_mysqlservice.cpp -o server_mysqlservice -lmysqlclient
4.2 服务端代码修改
调用业务模块的service的处理函数handle
#include"service.h"
...
void Socket::handleClient(int clientSocket) {
...
// 从标准输入读取数据并发送到客户端
std::string response=service.handle(buffer);
...
}
4.3 mysql数据模块代码
#pragma once
和使用传统的 include guards(如#ifndef
,#define
,#endif
)是用来防止头文件被多次包含的两种方法。
#pragma once:
- 是一种编译器特定的预处理指令,通常效率更高。
- 不需要像传统的 include guards 那样显式地定义和管理宏。
- 可能不支持所有编译器,尽管大多数现代编译器都支持。
传统的 include guards:
- 使用
#ifndef
,#define
,#endif
组合来防止头文件的多重包含。- 需要程序员显式地定义和管理宏,这些宏需要在每个头文件的开头和结尾正确地设置和使用。
- 是 C/C++ 标准的一部分,几乎所有编译器都支持。
#ifndef DB_H
#define DB_H
#include<mysql/mysql.h>
#include<string>
#include<iostream>
using namespace std;
#endif
//数据库配置资源
static string server="127.0.0.1";//服务器本地回环地址
static string user="mysql用户名";
static string password="mysql用户密码";
static string dbname="dictionary";//数据库的名字
//数据库操作类
class MySQL
{
public:
// 初始化数据库连接
MySQL()
{
_conn = mysql_init(nullptr);
}
// 释放数据库连接资源
~MySQL()
{
if (_conn != nullptr)
mysql_close(_conn);
}
// 连接数据库
bool connect()
{
MYSQL *p = mysql_real_connect(_conn, server.c_str(), user.c_str(),
password.c_str(), dbname.c_str(), 3306, nullptr, 0);
if (p != nullptr)
{
if (mysql_set_character_set(_conn, "utf8") != 0) {
std::cerr << "Error setting character set to utf8: " << mysql_error(_conn) << std::endl;
return false;
}
//mysql_query(_conn, "set names gbk");
cout<<"connect mysql success!";
}
else{
cout<<"connect mysql error";
}
return p;
}
// 查询操作
MYSQL_RES* query(string sql)
{
if (mysql_query(_conn, sql.c_str()))
{
cout<<"log:"<< sql << "query failed!";
return nullptr;
}
cout<<"query successed!"<<endl;
return mysql_use_result(_conn);
}
MYSQL* getConnection(){
return _conn;
}
private:
MYSQL *_conn;
};
4.4 业务代码实现
#include"db.h"
#include<sstream>
class Service{
public:
string handle(std::string message){
//组装sql语句
std::stringstream sql;
sql << "SELECT * FROM dictionary where chinese_word='" << message << "'";
//std::cout<<sql.str()<<endl;
MySQL mysql;
if(mysql.connect()){
MYSQL_RES *res=mysql.query(sql.str());
if(res!=nullptr){
MYSQL_ROW row=mysql_fetch_row(res);
if(row!=nullptr) return row[2];
else return "query unvalid!";
}
}else{
return "mysql connect failed!";
}
return "There is no English word for this Chinese word in the database!";
}
};
总结:
实现了一个电子字典的tiny编程,可以扩展(连接池,业务模块扩展,数据模块扩展)
主要分为5个模块(四个模块,服务模块,客户模块,数据模块,业务模块代码)
- 数据模块:采用Mysql数据库存储重要的数据信息
- 业务模块:通过传入的中文,调用业务处理模块(实际上就是调用Mysql查询语句)
- 服务端模块:与客户端通信,并且返回处理的业务
- 线程池模块:从线程池中分配线程
- 客户端模块:与服务端通信
参考: