# TinyWebServer **Repository Path**: binary_printer/tiny-web-server ## Basic Information - **Project Name**: TinyWebServer - **Description**: 仿照开源项目TinyWebServer,主要用于练习网络编程和并发编程。 原项目地址:https://github.com/qinguoyi/TinyWebServer - **Primary Language**: C++ - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2024-03-12 - **Last Updated**: 2024-04-16 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 锁模块 **作用:**封装了互斥锁、信号量、条件变量常用的操作。 # 阻塞队列 **用处:**基于数组实现一个循环队列,内部实现了线程互斥访问。 **实现思路:** - 插入/删除:采用`生产者——消费者`模型,并做了相应简化: - 插入时:每次插入都唤醒所有被阻塞的消费者;如果队列已满,也要唤醒消费者,随后直接返回失败,而不再继续等待。 - 删除时:相当于消费者消费商品,如果商品已空,进行阻塞等待。一直到生产者将其唤醒。 因此我们只需要一个条件变量:用来阻塞消费者的条件变量。 # 日志模块 ## 1. 功能 - 使用单例模式实现日志类。 - 实现了同步日志 - 实现了异步日志:使用了阻塞队列,通过阻塞队列实现了异步写日志。 - 按天、按行划分文件。 ## 2. 函数解析 ### 2.1 static Log *get_instance(): 通过使用局部静态成员,实现单例模式。C++11规范规定:当指令逻辑进入一个未被初始化的声明变量,那么所有并发执行应当等待该变量完成初始化。这就意味着在该静态变量初始化时,除了初始化它的线程,其余线程的并发操作停止,因此不会出现多个线程对其进行初始化。 ### 2.2 void *async_write_log() 通过while循环,**不断**从阻塞队列中取出日志数据,将其写入文件中。 这里要注意:对文件的访问是互斥的。因此要加锁。 ### 2.3 static void *flush_log_thread(void *args) 该函数实际上是一个线程的工作函数。 该函数内部调用了async_write_log(),可以从阻塞队列中取出一条日志数据,将其写入文件。 我们可以建立一个子线程,让该函数作为子线程的工作函数。从而实现不断地将阻塞队列里的日志数据写道文件中。 ### 2.4 bool init(const char *file_name, int close_log, int log_buf_size, int split_lines, int max_queue_size) **功能:**初始化一篇日志。该函数应该只被调用一次。 **参数:** - file_name:文件名。可以是单纯的文件名,也可以是全路径名。 - close_log:是否关闭日志。 - log_buf_size:日志缓冲区大小。 - split_lines:每个日志文件的最大行数。 - max_queue_size:阻塞队列最大容量。 - 如果>0:说明该日志是异步写。 - 否则,说明该日志是同步写。 **逻辑:** 1. 首先根据max_queue_size检查写的方式。如果是异步,建立一个子线程,将2.3的函数作回调函数。如果不是,跳过这一步。 2. 设置一些参数。如日期、每个日志文件的最大行号等。 3. 为文件命名: - 如果是传入的是文件名:日期+文件。 - 如果是传入的是全路径:路径+日期+文件。 4. 打开文件流。 到此,可以写文件了。 ### 2.5 void Log::write_log(int level, const char *format, ...) **功能:**写日志。每次写日志都要调用该函数。 **参数:** - level:日志等级。需要转换成字符串。 - format:日志格式。 - ...:日志的内容,通过`stdarg.h`头文件内封装的函数进行操作。 **逻辑:** 1. 首先判断是在旧日志文件中的写,还是要新建一个日志文件。有两种情况要新建一个日志文件: - 旧日志文件的行数已经超过了每个日志文件的最大行数。 - 相较于旧的日志文件,今天已经是新的一天。 2. 如果在新日志文件中写,需要新建一个文件,用流打开。 3. 无论是在新旧日志文件中写,接下来的操作都一样: 1. 获取当前时间信息。 2. 获取用户要写的东西。也就是`...`中传入的东西。 3. 将1、2中的内容写入缓冲区。 4. 根据同步/异步方式,将缓冲区的内容写入文件。 **注意:**每次写日志都只写一行。 ## 3. 难点 **几个重点函数:** - sprintf(): - snprintf():[snprintf 函数用法详解-CSDN博客](https://blog.csdn.net/m0_50668851/article/details/110000520) - gettimeofday():https://blog.csdn.net/shomy_liu/article/details/45110949 - strchr(): - strrchr(): - strcpy(): - strncpy(): - time(): - gettimeofday(): - localtime(): - va_list - va_start - \##\__VA_ARGS__ **重点结构体:** - struct tm: - time_t **重点头文件:** ``` time.h sys/time.h stdarg.h ``` ## 4. 可以优化的地方 # 数据库连接池 ## 1. 功能 封装一个数据库连接池,并提供多线程支持。 - 通过信号量来保证线程同步。 - 通过互斥锁来保证对数据库连接池的互斥访问。 - 通过RAII机制完成对数据库连接池创建和析构,避免忘记销毁数据库。 ## 2. 函数解析 ### 2.1 connection_pool *connection_pool::GetInstance() 通过静态局部变量实现的单例模式。 ### 2.2 void connection_pool::init(string url, string User, string PassWord, string DBName, int Port, int MaxConn, int close_log) **功能:** 对数据库连接池进行初始化。 **参数:** - url:主机地址 - User:数据库用户名 - PassWord:数据库密码 - DBName:数据库名 - Port:数据库端口号 - MaxConn:最大连接数 - close_log:是否关闭日志功能 **逻辑:** 1. 简单的初始化参数。 2. 通过For循环,根据最大连接数获取连接,并用链表存储。 3. 根据目前已经获取的可用连接数,设置信号量的值。 ### 2.3 MYSQL *connection_pool::GetConnection() **功能:** 从数据库连接处获取一个空闲的连接,同时更新正在使用的连接数和空闲的连接数。操作链表和连接数时注意互斥操作。 **逻辑:** 1. 判断是否由可用连接。如果没有,返回;有,进入2 2. 信号量执行P操作。 3. 互斥锁加锁。 4. 链条取出连接,修改正在使用的连接数和空闲的连接数。 5. 解锁。返回连接。 ### 2.4 bool connection_pool::ReleaseConnection(MYSQL *con) **功能:**释放当前使用的连接。将连接放回空闲队列。 **逻辑:**先加锁,保证对链表的互斥访问。再将连接放入链表内。再修改可用连接数和空闲的连接数。最后解锁。 ### 2.5 void connection_pool::DestroyPool() **功能:**销毁数据库连接池。 **逻辑:**先加锁。遍历空闲连接的链表,依次关闭连接。重置可用连接数可空闲的连接数。解锁。 ### 2.6 connectionRAII::connectionRAII(MYSQL **SQL, connection_pool *connPool) **功能:**根据传入的sql连接和数据库连接池,将RAII类进行初始化。 **参数:** - SQL:二重指针。因为数据库连接本身是指针类型,要对指针类型进行修改,需要二重指针。 - coonPool:数据库连接池。 ### 2.7 connectionRAII::~connectionRAII() **功能:**通过它来释放之间通过它建立的连接。 ## 3. 难点 ### 3.1 什么是RAII机制 **原理:** 当我们使用C++操作资源时,我们要经历申请资源——使用资源——释放资源三步,但有时我们会忘记最后释放资源,这会导致错误。而我们知道:在C++中,在类的生命周期结束后,会自动调用析构函数。于是我们想到:可不可以创建一个工具类,对于要使用的资源,在这个类的构造函数里申请,在这个类的析构函数里释放。如此,我们就可以通过这个类完成对资源的自动释放,十分方便。 **例子:** ```c++ #include #include #include using namespace std; CRITICAL_SECTION cs; int gGlobal = 0; class MyLock { public: MyLock() { EnterCriticalSection(&cs); } ~MyLock() { LeaveCriticalSection(&cs); } MyLock(const MyLock&) = delete; MyLock operator =(const MyLock&) = delete; }; void DoComplex(MyLock& lock) { } unsigned int __stdcall ThreadFun(PVOID pv) { // 利用lock变量构造加锁,析构解锁 MyLock lock; int* para = (int*)pv; //业务代码....... DoComplex(lock); for (int i = 0; i < 10; ++i) { ++gGlobal; cout << "Thread " << *para << endl; cout << gGlobal << endl; } return 0; } int main() { InitializeCriticalSection(&cs); int thread1, thread2; thread1 = 1; thread2 = 2; HANDLE handle[2]; handle[0] = (HANDLE)_beginthreadex(nullptr, 0, ThreadFun, (void*)&thread1, 0, nullptr); handle[1] = (HANDLE)_beginthreadex(nullptr, 0, ThreadFun, (void*)&thread2, 0, nullptr); WaitForMultipleObjects(2, handle, TRUE, INFINITE); return 0; } ``` ## 4. 改进点 # 线程池 ## 1. 功能 封装一个线程池。 - 使用同步模拟了proactor模式。由主线程充当异步线程,完成IO任务。 - 采用了半同步/半反应堆线程池。(这里的半同步指的是线程池中创建的工作线程,半反应堆就是上面的proactor模式) - 维护了一个请求队列。用来封装主线程和内核IO过后传来的任务。线程池内的工作线程被唤醒(通过信号量),随后处理改任务 **整体原理:** ## 2. 函数解析 下面函数的模板类实际使用实际使用时传入的是Http类。 ### 2.1 threadpool(int actor_model, connection_pool *connPool, int thread_number = 8, int max_request = 10000) **作用:** 根据传入的线程数对创建工作线程。创建完毕之后对线程进行分离。这样可以避免后续对工作线程的回收。 **参数:** - actor_model:模型 - connPool:数据库连接池 - thread_number :工作线程的数量。 - max_request :请求队列的最大长度。 ### 2.2 bool threadpool\::append(T *request, int state) **作用:** 向请求队列中添加新任务(起始就是封装好的HTTP请求)。 **参数:** - request:HTTP请求。 - state:HTTP请求的状态码。 ### 2.3 void *threadpool\::worker(void *arg) **作用:** 工作线程的任务函数,通过它调用后面的run()函数,实现工作线程对请求队列中请求的处理。 ### 2.4 void threadpool\::run() **作用:**从请求队列中取出请求,为该请求分配数据库连接,同时调用该请求的处理函数。注意信号量和互斥锁的使用。 ## 3. 难点 ### 3.1 Proactor 主线程和内核负责处理**读写数据、接受新连接等I/O操作**,工作线程仅负责**业务逻辑**,如处理客户请求。通常由**异步I/O**实现。 这里是模拟。用同步IO模拟Proactor。之所以模拟,是因为Linux下异步的I/O并不完善。aio系列函数并不是真正的操作系统级别,而是在用户空间模拟的异步,且仅仅支持基于本地文件的aio异步,对网络编程中的wsocket是不支持的。因此这里使用模拟。 模拟方式:以epoll_wait为例: 1. 主线程往epoll内核事件表注册socket上的读就绪事件。 2. 主线程调用epoll_wait等待socket上有数据可读 3. 当socket上有数据可读,epoll_wait通知主线程,主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。 4. 睡眠在请求队列上某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件 5. 主线程调用epoll_wait等待socket可写。 6. 当socket上有数据可写,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果。 简单来说:我们使用工作队列+线程模拟了Proactor的异步模式。 ### 3.2 半同步/半反应堆 这是一种`I/O处理单元`和`逻辑单元`完成任务的方式。是由`半同步——半异步`的方式演化而来。 **半同步——半异步:** - 同步线程处理客户逻辑。 - 异步线程处理I/O。 - 维护一个请求队列,当异步线程监听到逻辑请求,将其封装到请求队列中。 - 请求队列将会通知`某个工作在同步模式的工作线程`处理它。 **半同步——半反应堆:** - 主线程和内核充当**异步线程**,负责处理IO(通过epoll实现)。当检测到有读写事件时,主线程从socket上接受数据,并将其封装到请求队列。 - 所有工作线程沉睡在请求队列上(**同步线程**)。当有任务到来时,通过竞争锁,来抢占任务。