引言
在前一篇文章中,我们详细介绍了UDP协议和TCP协议的特点以及它们之间的异同点。本文将延续上文内容,重点讨论简单的TCP网络程序模拟实现。通过本文的学习,读者将能够深入了解TCP协议的实际应用,并掌握如何编写简单的TCP网络程序。让我们一起深入探讨TCP网络程序的实现细节,为网络编程的学习之旅添上一份精彩的实践经验。
一、TCP协议
TCP(Transmission Control Protocol)是一种面向连接的通信协议,它要求在数据传输前先建立连接,以确保数据的可靠传输。TCP通过序号、确认和重传等机制来保证数据的完整性和可靠性,同时还实现了拥塞控制和流量控制,以适应不同网络环境下的数据传输需求。由于TCP的可靠性和稳定性,它被广泛应用于网络通信中,包括网页浏览、文件传输、电子邮件等各种应用场景,成为互联网协议套件中的重要组成部分。详介绍可以看上一篇文章:UDP协议介绍 | TCP协议介绍 | UDP 和 TCP 的异同
二、TCP网络程序模拟实现
接下来,我们打算运用线程池技术,模拟实现一个简单的TCP网络程序。通过充分利用线程池,我们能够更有效地管理并发连接,从而提高程序的性能和稳定性。这一实践将有助于加深我们对网络编程关键概念和技术的理解和掌握。在前文中已经提到了线程池,这里就不再赘述其原理和作用。详细可以点击传送门:🚩 线程池
1. 预备代码
⭕ThreadPool.hpp(线程池)
#pragma once #include <iostream> #include <vector> #include <string> #include <queue> #include <pthread.h> #include <unistd.h> // 线程信息结构体 struct ThreadInfo { pthread_t tid; // 线程ID std::string name; // 线程名称 }; static const int defalutnum = 10; // 默认线程池大小为10 template <class T> class ThreadPool { public: void Lock() // 加锁 { pthread_mutex_lock(&mutex_); } void Unlock() // 解锁 { pthread_mutex_unlock(&mutex_); } void Wakeup() // 唤醒等待中的线程 { pthread_cond_signal(&cond_); } void ThreadSleep() // 线程休眠 { pthread_cond_wait(&cond_, &mutex_); } bool IsQueueEmpty() // 判断任务队列是否为空 { return tasks_.empty(); } std::string GetThreadName(pthread_t tid) // 获取线程名称 { for (const auto &ti : threads_) { if (ti.tid == tid) return ti.name; } return "None"; } public: static void *HandlerTask(void *args) // 线程任务处理函数 { ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args); std::string name = tp->GetThreadName(pthread_self()); while (true) { tp->Lock(); while (tp->IsQueueEmpty()) // 若任务队列为空,则线程等待 { tp->ThreadSleep(); } T t = tp->Pop(); // 从任务队列中取出任务 tp->Unlock(); t(); // 执行任务 } } void Start() // 启动线程池 { int num = threads_.size(); for (int i = 0; i < num; i++) { threads_[i].name = "thread-" + std::to_string(i + 1); pthread_create(&(threads_[i].tid), nullptr, HandlerTask, this); // 创建线程 } } T Pop() // 从任务队列中取出任务 { T t = tasks_.front(); tasks_.pop(); return t; } void Push(const T &t) // 将任务推入任务队列 { Lock(); tasks_.push(t); Wakeup(); Unlock(); } static ThreadPool<T> *GetInstance() // 获取线程池实例 { if (nullptr == tp_) // 若线程池实例为空 { pthread_mutex_lock(&lock_); if (nullptr == tp_) // 双重检查锁 { std::cout << "log: singleton create done first!" << std::endl; tp_ = new ThreadPool<T>(); // 创建线程池实例 } pthread_mutex_unlock(&lock_); } return tp_; } private: ThreadPool(int num = defalutnum) : threads_(num) // 构造函数 { pthread_mutex_init(&mutex_, nullptr); pthread_cond_init(&cond_, nullptr); } ~ThreadPool() // 析构函数 { pthread_mutex_destroy(&mutex_); pthread_cond_destroy(&cond_); } ThreadPool(const ThreadPool<T> &) = delete; // 禁用拷贝构造函数 const ThreadPool<T> &operator=(const ThreadPool<T> &) = delete; // 禁用赋值操作符,避免 a=b=c 的写法 private: std::vector<ThreadInfo> threads_; // 线程信息数组 std::queue<T> tasks_; // 任务队列 pthread_mutex_t mutex_; // 互斥锁 pthread_cond_t cond_; // 条件变量 static ThreadPool<T> *tp_; // 线程池实例指针 static pthread_mutex_t lock_; // 静态互斥锁 }; template <class T> ThreadPool<T> *ThreadPool<T>::tp_ = nullptr; // 初始化线程池实例指针为nullptr template <class T> pthread_mutex_t ThreadPool<T>::lock_ = PTHREAD_MUTEX_INITIALIZER; // 初始化静态互斥锁
以上代码实现了一个简单的线程池模板类 ThreadPool
,其中包含了线程池的基本功能和操作。
- 首先定义了一个线程信息结构体
ThreadInfo
,用来保存线程的ID和名称。 - 然后定义了一个模板类
ThreadPool
,其中包含了线程池的各种操作和属性:
Lock()
和Unlock()
分别用于加锁和解锁。Wakeup()
用于唤醒等待中的线程。ThreadSleep()
用于使线程进入休眠状态。IsQueueEmpty()
判断任务队列是否为空。GetThreadName()
根据线程ID获取线程名称。
- 定义了静态成员函数
HandlerTask
,作为线程的任务处理函数。在该函数中,线程会不断地从任务队列中取出任务并执行。 Start()
函数用于启动线程池,创建指定数量的线程,并将线程的任务处理函数设置为HandlerTask
。Pop()
函数用于从任务队列中取出任务。Push()
函数用于将任务推入任务队列。GetInstance()
函数用于获取线程池的实例,采用了双重检查锁(Double-Checked Locking)实现单例模式。- 线程池的构造函数和析构函数分别用于初始化和销毁互斥锁和条件变量。
- 最后使用静态成员变量初始化了线程池实例指针和静态互斥锁。
⭕makefile文件
.PHONY:all all:tcpserverd tcpclient tcpserverd:Main.cc g++ -o $@ $^ -std=c++11 -lpthread tcpclient:TcpClient.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f tcpserverd tcpclient
这段代码是一个简单的 Makefile 文件,用于编译生成两个可执行文件 tcpserverd
和 tcpclient
。
.PHONY: all
:声明all
为一个伪目标,表示all
不是一个实际的文件名,而是一个指定的操作。all: tcpserverd tcpclient
:定义了all
目标,它依赖于tcpserverd
和tcpclient
目标。当执行make all
时,会先编译tcpserverd
和tcpclient
。tcpserverd: Main.cc
:定义了生成tcpserverd
可执行文件的规则,依赖于Main.cc
源文件。使用g++
编译器进行编译,指定输出文件名为tcpserverd
,使用 C++11 标准,并链接 pthread 库。tcpclient: TcpClient.cc
:定义了生成tcpclient
可执行文件的规则,依赖于TcpClient.cc
源文件。同样使用g++
编译器进行编译,指定输出文件名为tcpclient
,使用 C++11 标准。.PHONY: clean
:声明clean
为一个伪目标。clean: rm -f tcpserverd tcpclient
:定义了clean
目标,用于清理生成的可执行文件。执行make clean
时将删除tcpserverd
和tcpclient
可执行文件。
⭕打印日志文件
#pragma once #include <iostream> #include <time.h> #include <stdarg.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <stdlib.h> #define SIZE 1024 #define Info 0 #define Debug 1 #define Warning 2 #define Error 3 #define Fatal 4 #define Screen 1 #define Onefile 2 #define Classfile 3 #define LogFile "log.txt" class Log { public: Log() { printMethod = Screen; // 默认打印方式为屏幕输出 path = "./log/"; // 默认日志文件路径为当前目录下的"log/"目录 } void Enable(int method) { printMethod = method; // 设置打印方式 } std::string levelToString(int level) { switch (level) { case Info: return "Info"; case Debug: return "Debug"; case Warning: return "Warning"; case Error: return "Error"; case Fatal: return "Fatal"; default: return "None"; } } void printLog(int level, const std::string &logtxt) { switch (printMethod) { case Screen: std::cout << logtxt << std::endl; // 在屏幕上输出日志信息 break; case Onefile: printOneFile(LogFile, logtxt); // 将日志信息写入单个文件中 break; case Classfile: printClassFile(level, logtxt); // 根据日志级别将日志信息写入不同的文件中 break; default: break; } } void printOneFile(const std::string &logname, const std::string &logtxt) { std::string _logname = path + logname; // 拼接日志文件路径 int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666); // 打开或创建一个文件,以追加方式写入 if (fd < 0) return; write(fd, logtxt.c_str(), logtxt.size()); // 将日志信息写入文件 close(fd); // 关闭文件 } void printClassFile(int level, const std::string &logtxt) { std::string filename = LogFile; filename += "."; filename += levelToString(level); // 生成日志文件名,例如"log.txt.Debug/Warning/Fatal" printOneFile(filename, logtxt); // 将日志信息写入对应级别的文件中 } ~Log() { } // 重载()运算符,用于输出日志信息 void operator()(int level, const char *format, ...) { time_t t = time(nullptr); struct tm *ctime = localtime(&t); // 获取当前时间 char leftbuffer[SIZE]; snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(), ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday, ctime->tm_hour, ctime->tm_min, ctime->tm_sec); // 格式化左侧部分,包括日志级别和时间信息 va_list s; va_start(s, format); char rightbuffer[SIZE]; vsnprintf(rightbuffer, sizeof(rightbuffer), format, s); // 格式化右侧部分,即用户自定义的日志内容 va_end(s); // 格式:默认部分+自定义部分 char logtxt[SIZE * 2]; snprintf(logtxt, sizeof(logtxt), "%s %s", leftbuffer, rightbuffer); // 拼接左右两侧的日志内容 printLog(level, logtxt); // 打印日志信息 } private: int printMethod; // 打印方式 std::string path; // 日志文件路径 };
这段代码是一个简单的日志记录类 Log
,它提供了几种不同的日志输出方式和日志级别。
#pragma once
: 使用编译器指令,确保头文件只被编译一次。- 定义了一些常量:
SIZE
: 缓冲区大小为 1024。- 日志级别常量:
Info
,Debug
,Warning
,Error
,Fatal
。 - 打印方式常量:
Screen
,Onefile
,Classfile
。 - 日志文件名常量:
LogFile
。
Log
类包含以下成员函数和变量:
printMethod
: 记录当前的打印方式,默认为屏幕输出。path
: 日志文件路径,默认为"./log/"。
- 构造函数
Log()
初始化printMethod
和path
。 Enable(int method)
: 设置日志的打印方式。levelToString(int level)
: 将日志级别转换为对应的字符串。- printLog(int level, const std::string &logtxt): 根据打印方式输出日志信息。
printOneFile(const std::string &logname, const std::string &logtxt)
: 将日志信息写入单个文件中。printClassFile(int level, const std::string &logtxt)
: 根据日志级别将日志信息写入不同的文件中。- 析构函数
~Log()
。
- 重载的函数调用运算符
operator()
: 接受日志级别和格式化字符串,格式化输出日志信息到不同的输出位置。
⭕将当前进程转变为守护进程
#pragma once #include <iostream> #include <cstdlib> #include <unistd.h> #include <signal.h> #include <string> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> const std::string nullfile = "/dev/null"; // 定义空设备文件路径 // 将当前进程变为守护进程的函数 void Daemon(const std::string &cwd = "") { // 1. 忽略一些异常信号,以避免对守护进程造成影响 signal(SIGCLD, SIG_IGN); // 忽略子进程结束信号 signal(SIGPIPE, SIG_IGN); // 忽略管道破裂信号 signal(SIGSTOP, SIG_IGN); // 忽略终止信号 // 2. 创建一个子进程并使父进程退出,确保守护进程不是进程组组长,创建一个新的会话 if (fork() > 0) exit(0); // 父进程退出 setsid(); // 创建新的会话,并成为该会话的首进程 // 3. 更改当前调用进程的工作目录,如果指定了工作目录则切换到相应目录 if (!cwd.empty()) chdir(cwd.c_str()); // 切换工作目录到指定路径 // 4. 将标准输入,标准输出,标准错误重定向至/dev/null,关闭不需要的文件描述符 int fd = open(nullfile.c_str(), O_RDWR); // 打开空设备文件 if (fd > 0) { dup2(fd, 0); // 标准输入重定向至空设备 dup2(fd, 1); // 标准输出重定向至空设备 dup2(fd, 2); // 标准错误重定向至空设备 close(fd); // 关闭打开的文件描述符 } }
这段代码实现了将当前进程转变为守护进程的函数 Daemon
。
- 忽略一些异常信号,避免对守护进程产生影响。
- 创建一个子进程并使父进程退出,确保守护进程不是进程组组长,创建一个新的会话。
- 更改当前调用进程的工作目录,如果指定了工作目录,则切换到相应目录。
- 将标准输入、标准输出和标准错误重定向至
/dev/null
,即空设备文件,关闭不需要的文件描述符,确保守护进程不产生输出和错误信息。
2. TCP 服务器端实现(TcpServer.hpp)
#pragma once #include <iostream> #include <string> #include <cstdlib> #include <cstring> #include <unistd.h> #include <sys/wait.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> #include <pthread.h> #include <signal.h> #include <signal.h> #include "Log.hpp" #include "ThreadPool.hpp" #include "Task.hpp" #include "Daemon.hpp" const int defaultfd = -1; const std::string defaultip = "0.0.0.0"; const int backlog = 10; // 最大连接请求队列长度 extern Log lg; enum { UsageError = 1, SocketError, BindError, ListenError, }; class TcpServer; // 线程数据结构,用于传递给线程处理函数 class ThreadData { public: ThreadData(int fd, const std::string &ip, const uint16_t &p, TcpServer *t): sockfd(fd), clientip(ip), clientport(p), tsvr(t) {} public: int sockfd; std::string clientip; uint16_t clientport; TcpServer *tsvr; }; // TCP服务器类 class TcpServer { public: // 构造函数,初始化端口和IP地址 TcpServer(const uint16_t &port, const std::string &ip = defaultip) : listensock_(defaultfd), port_(port), ip_(ip) { } // 初始化服务器 void InitServer() { // 创建套接字 listensock_ = socket(AF_INET, SOCK_STREAM, 0); if (listensock_ < 0) { lg(Fatal, "create socket, errno: %d, errstring: %s", errno, strerror(errno)); exit(SocketError); } lg(Info, "create socket success, listensock_: %d", listensock_); // 设置套接字选项,允许地址重用 int opt = 1; setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt)); // 绑定本地地址 struct sockaddr_in local; memset(&local, 0, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(port_); inet_aton(ip_.c_str(), &(local.sin_addr)); if (bind(listensock_, (struct sockaddr *)&local, sizeof(local)) < 0) { lg(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno)); exit(BindError); } lg(Info, "bind socket success, listensock_: %d"); // 监听套接字,开始接受连接请求 if (listen(listensock_, backlog) < 0) { lg(Fatal, "listen error, errno: %d, errstring: %s", errno, strerror(errno)); exit(ListenError); } lg(Info, "listen socket success, listensock_: %d"); } // 启动服务器 void Start() { // 将当前进程变为守护进程 Daemon(); // 启动线程池 ThreadPool<Task>::GetInstance()->Start(); lg(Info, "tcpServer is running...."); // 循环接受客户端连接并处理 while(true) { // 获取新连接 struct sockaddr_in client; socklen_t len = sizeof(client); int sockfd = accept(listensock_, (struct sockaddr *)&client, &len); if (sockfd < 0) { lg(Warning, "accept error, errno: %d, errstring: %s", errno, strerror(errno)); continue; } // 获取客户端IP和端口 uint16_t clientport = ntohs(client.sin_port); char clientip[32]; inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); // 打印客户端连接信息 lg(Info, "get a new link..., sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport); // 创建任务对象并加入线程池处理 Task t(sockfd, clientip, clientport); ThreadPool<Task>::GetInstance()->Push(t); } } // 析构函数 ~TcpServer() {} private: int listensock_; // 监听套接字 uint16_t port_; // 端口号 std::string ip_; // IP地址 };
这段代码是一个简单的TCP服务器的实现,包括了创建套接字、绑定地址、监听连接、接受客户端连接等基本操作。
3. TCP 客户端实现(main函数)
#include <iostream> #include <cstring> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> void Usage(const std::string &proc) { std::cout << "\n\rUsage: " << proc << " serverip serverport\n" << std::endl; } // ./tcpclient serverip serverport int main(int argc, char *argv[]) { // 检查命令行参数是否正确 if (argc != 3) { Usage(argv[0]); exit(1); } std::string serverip = argv[1]; uint16_t serverport = std::stoi(argv[2]); // 设置服务器地址信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverport); inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr)); while (true) { int cnt = 5; // 连接重试次数 int isreconnect = false; // 是否需要重连 int sockfd = 0; // 创建套接字 sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { std::cerr << "socket error" << std::endl; return 1; } do { // 尝试连接服务器 int n = connect(sockfd, (struct sockaddr *)&server, sizeof(server)); if (n < 0) { isreconnect = true; cnt--; std::cerr << "connect error..., reconnect: " << cnt << std::endl; sleep(2); // 等待一段时间后重连 } else { break; } } while (cnt && isreconnect); if (cnt == 0) { std::cerr << "user offline..." << std::endl; break; } // 与服务器建立连接后进行通信 while (true) { std::string message; std::cout << "Please Enter# "; std::getline(std::cin, message); // 向服务器发送消息 int n = write(sockfd, message.c_str(), message.size()); if (n < 0) { std::cerr << "write error..." << std::endl; } // 从服务器接收消息并显示 char inbuffer[4096]; n = read(sockfd, inbuffer, sizeof(inbuffer)); if (n > 0) { inbuffer[n] = 0; std::cout << inbuffer << std::endl; } } // 关闭套接字 close(sockfd); } return 0; }
温馨提示
感谢您对博主文章的关注与支持!如果您喜欢这篇文章,可以点赞、评论和分享给您的同学,这将对我提供巨大的鼓励和支持。另外,我计划在未来的更新中持续探讨与本文相关的内容。我会为您带来更多关于Linux以及C++编程技术问题的深入解析、应用案例和趣味玩法等。如果感兴趣的话可以关注博主的更新,不要错过任何精彩内容!
再次感谢您的支持和关注。我们期待与您建立更紧密的互动,共同探索Linux、C++、算法和编程的奥秘。祝您生活愉快,排便顺畅!