Linux多线程Web服务器(C++实现)

news2024/11/17 16:40:25

本文实现的是基于Reactor模式+epoll(边缘触发)+非阻塞socket+非阻塞IO+线程池的Web服务器,可以处理GET、POST两种请求,完成展示主页、获取图片、获取视频、登录及注册共五种功能。


原理图:

上图为本文实现的服务器的原理图,采用了单Reactor多线程的模式,在主线程中用epoll监听一个listenFd与多个connFd。

  • 若发生建立连接的事件,则交给accept单元处理,再把生成的connFd传给epoll管理;

  • 若发生可读\可写事件,则添加到线程池的任务队列中,由池中空闲的子线程拿取任务并处理;

  • 此外,若是客户端请求涉及数据库文件,则还需要从数据库连接池中拿出一个空闲的数据库连接,通过这个连接进行数据库文件的增删查改操作。


本文实现的服务器还采用了epoll的边缘触发模式,相比于水平触发减少了更多的epoll系统调用次数,在高并发的情况下效率更高。下面就ET(边缘触发)和LT(水平)触发进行介绍,并给出基于此实现的读写函数。

关于上图的详解:彻底学会使用epoll(一)——ET模式实现分析

因为在ET模式下,如果read一次没有读尽buffer中的数据,那么下次将得不到读就绪的通知,造成buffer中已有的数据无机会读出,除非有新的数据再次到达,write也同理。所以为了解决这一问题,就需要在读的时候尽力去读,写的时候尽力去写,即使用while循环去读写,直至出错或读写完毕。

使用while循环读写见: HTTP连接(读取请求+解析请求+生成响应+回送响应)
循环读:ssize_t HttpConn::read(int* errno_)
循环写:ssize_t HttpConn::write(int* errno_)

使用while循环去读写时,如果是阻塞IO就会因为 无数据可读/没空间可写 而一直阻塞在那里,所以采用非阻塞IO,一旦 无数据可读/没空间可写 就立刻返回-1,errno=EAGAIN(设置socket为非阻塞socke)。

bool WebServer::setFdNonblock(int fd)
{
    int flags;
    if((flags=fcntl(fd,F_GETFL,0))<0)    
        return false;
    flags |= O_NONBLOCK;
    if(fcntl(fd,F_SETFL,flags)<0)
        return false;
    return true;
}

因为ET模式下被唤醒(返回就绪)的条件为:

对于读取操作:

(1) 当buffer由不可读状态变为可读的时候,即由空变为不空的时候。

(2) 当有新数据到达时,即buffer中的待读内容变多的时候。

(3) 当buffer中有数据可读(即buffer不空)且用户对相应fd进行epoll_mod IN事件时

对于写操作:

(1) 当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候。

(2) 当有旧数据被发送走时,即buffer中待写的内容变少得时候。

(3) 当buffer中有可写空间(即buffer不满)且用户对相应fd进行epoll_mod OUT事件时

所以下面实现的逻辑为:

readFrom调用client->read(&readErrno)循环读取,直至读取完成/读取失败,读取失败又分为“暂时无数据可读”或”其他原因“,如果是其他原因失败则直接关闭连接,如果是"暂时无数据可读",则进入process函数,判断已经收集到的请求是否可以解析成功,解析成功则更换监听的事件为EPOLLOUT,解析失败则继续监听EPOLLIN事件,继续读取客户端请求。

writeTo调用client->write(&writeErrno)循环写入,直至全部写完/写入失败,写入失败又分为”暂时无空间可写“或”其他原因“,如果是其他原因则直接关闭连接,如果是”暂时无空间可写“,则继续监听EPOLLOUT事件,继续写入响应。如果全部写完了,需要判断是否建立了HTTP长连接,如果建立了,那么就要尽量在一个TCP连接内完成多条HTTP报文的传送,所以进入process函数,判断此时收集到的请求是否可以成功解析,如果成功则继续监听EPOLLOUT,让本条请求的响应在下一轮epoll_wait中就绪,如果失败,说明此时收集到的请求仍然不足,改为监听EPOLLIN,继续读取客户端请求。

void WebServer::readFrom(HttpConn* client)
{
    int readErrno=0;
    int ret=client->read(&readErrno);//client->raed()用while循环去尽力读
    if(ret<=0&&readErrno!=EAGAIN)//读出错,关闭连接
    {
        closeConn(client);
        return ;
    }
    process(client);
}

void WebServer::writeTo(HttpConn* client)
{
    int writeErrno=0;
    int ret=client->write(&writeErrno);//client->write()用while循环去尽力写
    if(client->isWriteOver())//数据已全部写完 
    {
        if(client->isKeepAlive()) //建立的是Http长连接
        {
            process(client);
            return;
        }
    }
    if(ret<0&&writeErrno==EAGAIN) //数据未读完,加入rdlist中下次继续读
    {
        epoller_p->modFd(client->getFd(), connEvent | EPOLLOUT);
        return ;
    }
    closeConn(client);
}

void WebServer::process(HttpConn* client)
{
    if(client->process()) //处理客户端请求成功
    {
        epoller_p->modFd(client->getFd(), connEvent | EPOLLOUT);//监听事件改为EPOLLOUT
    } 
    else 
    {
        epoller_p->modFd(client->getFd(), connEvent | EPOLLIN);//监听事件仍为EPOLLIN,继续读
    }
}

本文实现的Web服务器还设置了优雅关闭的选项:

struct linger optLinger;
    optLinger.l_onoff=1;
    optLinger.l_linger=1;
    if(setsockopt(listenFd,SOL_SOCKET,SO_LINGER,&optLinger,sizeof(optLinger))<0)
    {
        LOG_ERROR("%s","Set SO_LINGER Option Error!");
        return false;
    }

开启此套接字选项后,close()不会在调用后立刻返回,而是在延滞optLinger.l_linger 时间后才返回,在这一段时间内,close()函数并未关闭读写端,所以可以获取到客户端对于发往它的数据和FIN的ACK确认,故称优雅关闭。

当然,如果optLinger.l_linger设置不合理,在延滞时间内并未收到对端的确认,那么close返回-1,errno=EWOULDBLOCK,服务器端关闭,此后如果客户端的确认姗姗来迟,面对已经关闭的服务器端,只会收到RST,这是我们不想看到的。

所以,最好的办法其实是服务器端采用shutdown半关闭(关闭写端),这样读端read函数就会一直阻塞,可以读取到客户端发来的对于数据和FIN的确认,也可以在之后读取到客户端在处理完发来数据后调用close发出的FIN,服务器的读端read读到FIN后,直接返回,读取结束。

所以read的成功返回表明了:服务器端既获得了客户端对于发往它的数据和FIN的确认(TCP),又获得了客户端正确读取发来数据的确认(客户端的用户进程)。相比于close()+SO_LINGER,得到的消息更多。


此外,本文的Web服务器还设置了地址复用的选项:

int optReuseaddr=1;
    if(setsockopt(listenFd,SOL_SOCKET,SO_REUSEADDR,&optReuseaddr,sizeof(optReuseaddr))<0)
    {
        LOG_ERROR("%s","Set SO_REUSEADDR Option Error!");
        return false;
    }

它的功能是,允许启动一个监听服务器并捆绑其众所周知的端口,即使以前建立的将该端口用作它们的本地端口的连接仍然存在。

这种情况常见于:

先启动一个监听服务器,连接请求到达,派生一个子进程来处理该客户,之后监听服务器终止,但是子进程仍然继续为该连接上的客户提供服务,重启监听服务器时就会失败。

这里重启失败是失败在bind函数绑定端口时,它试图绑定一个已有连接上的端口,所以失败。

当然,本文实现的基于多线程的Web服务器,一旦主线程的监听服务器终止,派生的子线程也会随之终止,大家都关闭了,也就不存在上述的重启失败问题。


以上就是本文实现的Web服务器的重点内容,下面是全部代码:

WebServer类结构 webserver.h

#ifndef WEBSERVER_H
#define WEBSERVER_H

#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unordered_map>
#include <errno.h>
#include <assert.h>

#include "heaptimer.h"
#include "epoller.h"
#include "../pool/threadpool.h"
#include "../pool/sqlconnpool.h"
#include "../http/httpconn.h"
#include "../log/log.h"

class  WebServer
{
public:
    WebServer();
    ~WebServer();
    bool loadConfigFile();
    void start();

private:
    void dealListen();
    void dealRead(HttpConn* client);
    void dealWrite(HttpConn* client);
    void closeConn(HttpConn* client);

    void flushTime(HttpConn* client);
    void addClient(int fd,const sockaddr_in& addr);
    void readFrom(HttpConn* client);
    void writeTo(HttpConn* client);
    void process(HttpConn* client);

    bool setFdNonblock(int fd);
    bool initSocket();
    
    int port;
    int timeOutMs;
    bool isClose;
    int listenFd;
    char* srcDir;

    uint32_t listenEvent;
    uint32_t connEvent;
    std::unique_ptr<HeapTimer> timer_p;
    std::unique_ptr<ThreadPool> threadPool_p;
    std::unique_ptr<Epoller> epoller_p;
    std::unordered_map<int,HttpConn> users;
    const int maxUserNum = 65536;
};

#endif // !WEBSERVER_H

WebServer类实现 webserver.cpp

#include "webserver.h"

bool WebServer::loadConfigFile()//加载配置文件
{
    FILE* fp = fopen("./webserver.ini", "r");
    if(fp==nullptr)
    {
        return false;
    }    
    while(!feof(fp)) 
    {
        char line[1024] = {0};
        fgets(line, 1024, fp);
        string str = line;
        int idx = str.find('=', 0);
        if (idx == -1)
            continue;
        int endidx = str.find('\n', idx);
        string key = str.substr(0, idx);
        string value = str.substr(idx+1,endidx-idx-1);
        if (key == "port")    
            port = stoi(value);
        else if (key == "timeOutMs")
            timeOutMs = stoi(value);
        else if (key == "sqlConnMaxNum")
            SqlConnPool::getInstance(stoi(value));
        else if (key == "threadNum") 
            threadPool_p=std::unique_ptr<ThreadPool>(new ThreadPool(stoi(value)));
        else if (key == "logQueSize")
            Log::getInstance()->init(stoi(value));
    }
    fclose(fp);
    return true;
}

WebServer::WebServer():isClose(false),timer_p(new HeapTimer()),epoller_p(new Epoller())
{
    if (!loadConfigFile())//配置失败
    {
        isClose=true;
        LOG_ERROR("%s","Load Config File Fail!");
        return ;
    }
    srcDir=getcwd(nullptr,256);
    strncat(srcDir,"/resources/",15);
    HttpConn::userCount=0;
    HttpConn::srcDir=srcDir;
    listenEvent=EPOLLRDHUP|EPOLLIN|EPOLLET;
    connEvent=EPOLLONESHOT|EPOLLRDHUP|EPOLLET;
    if(!initSocket())
        isClose=true;
    if(isClose)
        LOG_ERROR("%s","========== Server init error!==========");
    else
        LOG_INFO("%s", "========== Server init success!========");
}

WebServer::~WebServer()
{
    close(listenFd);
    isClose=true;
    free(srcDir);
}

void WebServer::start() 
{
    int timeMs=-1;
    while(!isClose)
    {
        if(timeOutMs>0)
        {
            timeMs=timer_p->getNextTick();
        }
        int eventCnt=epoller_p->wait(timeMs);
        for(int i=0;i<eventCnt;i++)
        {
            int fd=epoller_p->getEventFd(i);
            uint32_t events=epoller_p->getEvents(i);
            if(fd==listenFd)
            {
                dealListen();
            }
            else if(events&EPOLLIN)
            {
                dealRead(&users[fd]);
            }
            else if(events&EPOLLOUT)
            {
                dealWrite(&users[fd]);
            }
            else if(events&(EPOLLRDHUP|EPOLLHUP|EPOLLERR))
            {
                closeConn(&users[fd]);
            }
            else
            {
                LOG_ERROR("%s","Unexpected Event Happen!");
            }
        }
    }
    
}

void WebServer::dealListen()
{
    struct sockaddr_in addr;
    socklen_t len=sizeof(addr);
    while(true)
    {
        int connfd=accept(listenFd,(struct sockaddr*)&addr,&len);
        if(HttpConn::userCount>=maxUserNum)
        {
            close(connfd);
            LOG_ERROR("%s","Server Users Full!");
            return ;
        }
        if(connfd<=0)
        {
            return ;
        }
        else
        {
            addClient(connfd,addr);
        }
    }
}

 void WebServer::dealRead(HttpConn* client)
 {
    flushTime(client);
    threadPool_p->addTask(std::bind(&WebServer::readFrom,this,client));
 }

void WebServer::dealWrite(HttpConn* client)
{
    flushTime(client);
    threadPool_p->addTask(std::bind(&WebServer::writeTo,this,client));
}

void WebServer::closeConn(HttpConn* client)
{
    epoller_p->delFd(client->getFd());
    client->closeConn();
}

void WebServer::flushTime(HttpConn* client)
{
    if(timeOutMs>0)
    {
        timer_p->adjust(client->getFd(),timeOutMs);
    }
}

void WebServer::addClient(int connfd,const sockaddr_in& addr)
{
    users[connfd].init(connfd,addr);
    if(timeOutMs>0)
    {
        timer_p->add(connfd,timeOutMs,std::bind(&WebServer::closeConn,this,&users[connfd]));
    }
    epoller_p->addFd(connfd,connEvent|EPOLLIN);
    setFdNonblock(connfd);
}

void WebServer::readFrom(HttpConn* client)
{
    int readErrno=0;
    int ret=client->read(&readErrno);
    if(ret<=0&&readErrno!=EAGAIN)
    {
        closeConn(client);
        return ;
    }
    process(client);
}

void WebServer::writeTo(HttpConn* client)
{
    int writeErrno=0;
    int ret=client->write(&writeErrno);
    if(client->isWriteOver()) 
    {
        if(client->isKeepAlive()) 
        {
            process(client);
            return;
        }
    }
    if(ret<0&&writeErrno==EAGAIN) 
    {
        epoller_p->modFd(client->getFd(), connEvent | EPOLLOUT);
        return ;
    }
    closeConn(client);
}

void WebServer::process(HttpConn* client)
{
    if(client->process()) 
    {
        epoller_p->modFd(client->getFd(), connEvent | EPOLLOUT);
    } 
    else 
    {
        epoller_p->modFd(client->getFd(), connEvent | EPOLLIN);
    }
}

bool WebServer::setFdNonblock(int fd)
{
    int flags;
    if((flags=fcntl(fd,F_GETFL,0))<0)    
        return false;
    flags |= O_NONBLOCK;
    if(fcntl(fd,F_SETFL,flags)<0)
        return false;
    return true;
}

bool WebServer::initSocket()
{
    if(port>65535||port<1024)
    {
        LOG_ERROR("Select Port:%d Error!",port);
        return false;
    }
    listenFd=socket(AF_INET,SOCK_STREAM,0);
    if(listenFd<0)
    {
        LOG_ERROR("%s","Create Socket Error!");
        return false;
    }
    struct linger optLinger;
    optLinger.l_onoff=1;
    optLinger.l_linger=1;
    if(setsockopt(listenFd,SOL_SOCKET,SO_LINGER,&optLinger,sizeof(optLinger))<0)
    {
        LOG_ERROR("%s","Set SO_LINGER Option Error!");
        return false;
    }
    int optReuseaddr=1;
    if(setsockopt(listenFd,SOL_SOCKET,SO_REUSEADDR,&optReuseaddr,sizeof(optReuseaddr))<0)
    {
        LOG_ERROR("%s","Set SO_REUSEADDR Option Error!");
        return false;
    }
    struct sockaddr_in addr;
    addr.sin_family=AF_INET;
    addr.sin_addr.s_addr=htonl(INADDR_ANY);
    addr.sin_port=htons(port);
    if(bind(listenFd,(struct sockaddr*)&addr,sizeof(addr))<0)
    {
        LOG_ERROR("%s","Bind Port:%d Error!",port);
        return false;
    }
    if(listen(listenFd,5)<0)
    {
        LOG_ERROR("%s","Listen Port:%d Error!",port);
        return false;
    }
    if(!epoller_p->addFd(listenFd,listenEvent))
    {
        LOG_ERROR("%s","Add ListenFd:%d in Epoll Error!",listenFd);
        return false;
    }
    if(!setFdNonblock(listenFd))
    {
        LOG_ERROR("%s","Set ListenFd:%d Nonblock Error!",listenFd);
        return false;
    }
    return true;
}

webserver.ini

# WEB服务器配置文件
#端口号
port=8888
#超时时间
timeOutMs=500
#最大sql连接数
sqlConnMaxNum=1000
#线程数 
threadNum=8
#日志队列最大容量
logQueSize=1024

本项目已在github开源,全部代码见:

1410138/MyWebServer: C++ Linux Web服务器 (github.com)

其余部分的介绍及部分代码见本专栏:

webServer_{(sunburst)}的博客-CSDN博客

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/160348.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

国产GPU芯片迎来突破,算力全球第一,中文编程也有好消息

苦&#xff0c;芯片久矣&#xff0c;终&#xff0c;迎来突破&#xff0c;实在振奋人心&#xff01;最近&#xff0c;国产GPU芯片传来了好消息&#xff0c;国产自研首款通用芯片&#xff0c;以每秒千万亿次的计算能力&#xff0c;创全球算力记录&#xff0c;芯片领域实现跨越式的…

包体积优化 · 实战论 · 怎么做包体优化? 做好能晋升吗? 能涨多少钱?

“ 【小木箱成长营】包体积优化系列文章&#xff1a; 包体积优化 方法论 揭开包体积优化神秘面纱 包体积优化 工具论 初识包体积优化 BaguTree 包体积优化录播视频课 ”一、引言 Hello&#xff0c;我是小木箱&#xff0c;欢迎来到小木箱成长营系列教程&#xff0c;今天将分…

论文投稿指南——中文核心期刊推荐(农业工程)

【前言】 &#x1f680; 想发论文怎么办&#xff1f;手把手教你论文如何投稿&#xff01;那么&#xff0c;首先要搞懂投稿目标——论文期刊 &#x1f384; 在期刊论文的分布中&#xff0c;存在一种普遍现象&#xff1a;即对于某一特定的学科或专业来说&#xff0c;少数期刊所含…

实战10:基于opencv的数字图像处理:边缘检测 (完整代码+详细教程)

给出“离散拉普拉斯算子”一般形式的数学推导 离散值的导数使用差分代替: 所以: 以(x, y)为中心点,在水平和垂直方向上应用拉普拉斯算子,滤波器(对应a=1的情况)为:

Spring Cloud Alibaba学习指南

文章目录背景介绍主要功能主要组件参考文档Spring Cloud Alibaba githubNacos官方文档Nacos运维手册Sentinel官方文档Spring Cloud Alibaba SentinelSeata官方文档Spring Cloud Alibaba 英语文档应用脚手架背景 由于在2018年Netflix公司宣布对其核心组件Hystrix、Ribbon、zuul…

远端连接服务器详解

昨天决定入手了一台腾讯轻量应用服务器&#xff0c;在连接的过程中遇到很多问题&#xff0c;浪费了很多时间&#xff0c;所以在这里对这些问题进行整理分享给大家&#xff01;&#xff01;&#xff01;系统的安装OpenCloudOS是完全中立、全面开放、安全稳定、高性能的操作系统及…

JVM调优之GC日志分析及可视化工具介绍

JVM调优之GC日志分析及可视化工具介绍 文章目录JVM调优之GC日志分析及可视化工具介绍GC日志参数GC日志参数常用垃圾收集器参数GC日志分析日志的含义使用 ParNew Serial Old 的组合进行内存回收使用 Parallel Scavenge Parallel Old 的组合进行内存回收大对象回收分析日志分析…

药品溶出曲线数据库

药物在体外的溶出行为&#xff0c;可以用来预测体内的崩解、溶出和吸收情况&#xff0c;同时药物体外溶出行为能够在一定程度上反映出制剂的质量。而溶出曲线特别是不同溶出介质的多条溶出曲线&#xff0c;可更加全面、灵敏地反映出上述关键要素的变化。当药物溶出曲线中药物品…

电脑磁盘重新分配空间的简单步骤(无损数据空间转移)

目录 一、前言 遇到问题 解决方式 二、磁盘现状与实现目标 磁盘现状 实现目标 三、操作步骤 &#xff08;一&#xff09;关闭电脑磁盘加密 &#xff08;二&#xff09;下载安装分区助手 &#xff08;三&#xff09;分配空间教程 注意事项 磁盘空间移动成功 一、前…

芯片设计五部曲之二 | 图灵艺术家——数字IC

《芯片设计五部曲》&#xff1a;模拟IC、数字IC、存储芯片、算法仿真和总结篇&#xff08;排名不分先后 上一集我们已经说了&#xff0c;模拟IC&#xff0c;更像是一种魔法。 我们深度解释了这种魔法的本质&#xff0c;以及如何在模拟芯片设计的不同阶段&#xff0c;根据常见的…

千万别乱用!Lombok不是万能的

背景 在使用Lombok构建无参构造器的时候&#xff0c;同事同时使用了Data和Builder&#xff0c;造成了编译不通过的问题&#xff01; Data使用说明 Lombok的Data注解可以为我们生成无参构造方法和类中所有属性的Getter和Setter方法。这样在我们开发的过程中&#xff0c;我们就…

seaborn的调色板、刻度、边框、标签、数据集等的一些解释

文章目录前言数据集构建整体风格设置调色板x轴的刻度值设置sns.lineplot实例前言 seaborn是对matplotlib进一步封装的库&#xff0c;可以用更少的代码&#xff0c;画出更好看的图。 官网&#xff1a;https://seaborn.pydata.org/index.html 下面记录一下seaborn的基础用法 数…

【日常业务开发】策略+工厂模式优化 if...else判断逻辑

【日常业务开发】策略工厂模式优化 if...else判断逻辑场景策略工厂模式优化利用Spring自动注入的特点处理继承InitializingBean静态工厂方法调用处理注解CommandLineRunnerApplicationContextAware处理/ApplicationListener\<ContextRefreshedEvent>场景 业务中经常有支…

一行代码写一个谷歌插件 —— Javascript

回顾 前期 【提高代码可读性】—— 手握多个代码优化技巧、细数哪些惊艳一时的策略_0.活在风浪里的博客-CSDN博客代码优化对象策略https://blog.csdn.net/m0_57904695/article/details/128318224?spm1001.2014.3001.5501 目录 技巧一&#xff1a;谷歌插件 第一步: 第二步…

Tomcat的安装和使用

作者&#xff1a;~小明学编程 文章专栏&#xff1a;JavaEE 格言&#xff1a;热爱编程的&#xff0c;终将被编程所厚爱。 目录 下载Tomcat tomcat文件介绍 启动Tomcat 简单的部署静态页面 HTTP 服务器&#xff0c;就是在 TCP 服务器的基础上&#xff0c;加上了一些额外的功能…

计算机网络 - 概述

文章目录前言一、计算机网络概述1.1、计算机网络在信息时代的作用1.2、Intnet概述网络、互连网&#xff08;互联网&#xff09;和因特网因特网发展阶段因特网的组成1.3、计算机网络的定义和分类定义分类1.4、报文交换方式电路交换分组交换报文交换三种交换方式对比1.5、性能指标…

5-1输入/输出管理-I/O管理概述

文章目录一.I/O设备二.I/O控制器/设备控制器三.I/O控制方式1.程序直接控制方式2.中断驱动方式3.DMA方式&#xff08;直接存储器存取&#xff09;4.通道控制方式四.I/O子系统的层次结构五.输入/输出应用程序接口&设备驱动程序接口&#xff08;一&#xff09;输入/输出应用程…

【学Vue就跟玩一样】组件-单文件组件

单文件组件在实际开发中是经常使用的&#xff0c;那么如何创建一个单文件组件呢&#xff1f;那么本篇就来简单入一下单文件组件。一&#xff0c;创建单文件组件 1.切换到你想要创建该文件的目录下&#xff0c;我这里切换的是desktop这个目录&#xff0c;当然&#xff0c;也可以…

大学高校供配电系统谐波危害及治理方案

摘要&#xff1a;安全科学用电是保障高校教学科研及办公的基础条件&#xff0c;随着现代化教学、电子图书馆等先进教学手段的不断引入&#xff0c;智能给排水、变频空调、电梯传送系统等配套设施以及电子镇流的照明灯具设备等大量非线性电力电子设备涌现&#xff0c;很多高等院…

「科普」带你认识5G基站

随着5G时代的到来&#xff0c;为了信号的稳定传输&#xff0c;为了覆盖面更广&#xff0c;5G基站作为5G规模组网的“先行军”&#xff0c;其建设至关重要。 那么&#xff0c;5G时代的基站是如何建设的呢&#xff1f;下面就来给大家介绍一下。 截至2022年末&#xff0c;我国移…