Linux —— epoll(1)

目录

[1. epoll 是什么?核心定位?解决什么问题?](#1. epoll 是什么?核心定位?解决什么问题?)

[2. epoll的接口](#2. epoll的接口)

[2.1 epoll_create()](#2.1 epoll_create())

[2.2 epoll_ctl()](#2.2 epoll_ctl())

[2.3 epoll_wait()](#2.3 epoll_wait())

[3. epoll的原理](#3. epoll的原理)

[3.1 自己的理解](#3.1 自己的理解)

[3.2 理性 ----- 源代码 + 重谈 socket 对象](#3.2 理性 ----- 源代码 + 重谈 socket 对象)

[4. 代码](#4. 代码)

[5. epoll 工作模式](#5. epoll 工作模式)

[5.1 LT:水平触发工作模式(Level Triggered)](#5.1 LT:水平触发工作模式(Level Triggered))

[5.2 ET:边缘触发工作模式(Edge Triggered)](#5.2 ET:边缘触发工作模式(Edge Triggered))

[5.3 LT 和 ET 的设计??](#5.3 LT 和 ET 的设计??)

[5.4 LT vs ET](#5.4 LT vs ET)


1. epoll 是什么?核心定位?解决什么问题?

  • epoll 是一个多路复用,多路转接的一种具体方案
  • epoll 核心定位:IO = 等 + 拷贝,一次等待多个fd -> 任意一个或者多个fd就绪,通知用户 -> 通过等待多个fd(手段),通知用户哪些fd就绪了(目的);select / poll / epoll 的核心定位就是:就绪事件通知机制!!!
  • epoll **是为处理大批量句柄(文件描述符)而作了改进的poll。**它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44),它几乎具备了之前所说的⼀切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法

2. epoll的接口

2.1 epoll_create()

int epoll_create(int size);

  • 参数:size:被忽略的,设置上大于 0 的值就行了,对应的就是底层红黑树节点的个数的上限,现在是不需要这个上限的,所以忽略
  • 返回值:成功,返回文件描述符;失败,返回-1;返回值通常代表的是 epoll 模型

2.2 epoll_ctl()

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll_ctl:向指定的文件描述符**(** int epfd ) ,增加、删除 or 修改(int op ),对指定文件描述符上(int fd ),指定事件(struct epoll_event *event )的关心,是一个 **输入型数组!!**是用户告诉内核!!

  • int op 常见有3种:
  • struct epoll_event 的结果如下

events 可以是以下几个宏的集合:

  • EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
  • EPOLLOUT : 表示对应的文件描述符可以写;
  • EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这⾥应该表⽰有带外数据到来);
  • EPOLLERR : 表示对应的文件描述符发⽣错误;
  • EPOLLHUP : 表示对应的文件描述符被挂断;
  • EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
  • EPOLLONESHOT:只监听⼀次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加⼊到EPOLL红⿊树⾥.返回值:

2.3 epoll_wait()

int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

参数:

1. int epfd:就是 epoll_create 创建出来的返回值

2. int timeout:同poll一样

  • timeout = 1000,表示1000以内,阻塞等待,没有就绪,直到超时,超时之后,自动进行timeout
  • timeout = -1,代表的是阻塞等待,除非有文件描述符就绪,否则不返回
  • timeout = 0,代表的是非阻塞式等待

3. struct epoll_event *events, int maxevents:是一个输出型数组,是内核告诉用户

返回值:和select、poll一样

  • > 0:有多少个 fd 就绪
  • == 0:超时,没有就绪,也没有报错
  • < 0:select 自己调用出错

3. epoll的原理

3.1 自己的理解

3.2 理性 ----- 源代码 + 重谈 socket 对象

创建 epoll 模型,实际上是创建一个 eventpoll 的数据结构对象。

为什么 epoll_create 的返回值是一个文件描述符???

返回文件描述符也意味着要创建 struct file 对象!!!

所以调用 epoll_ctl 拿着文件描述符 -> 找到文件对象 -> 找到 private_data -> 找到eventpoll 就可以读取红黑树、就绪队列,所以这就是为什么在epoll_ctl()函数中的第一个参数是 fd 的根本原因。

回调机制在哪里???什么时候会被触发???

实际上在内核中创建套接字时,创建 struct file ,创建 struct socket ,创建 tcp_socket 或是 udp_socket ,所以获取连接的时候,accept 内核中得到文件描述符,其实创建tcp套接字,创建 socket,创建struct file,分配文件描述符,返回出去。在底层的任意一层,收到了sock结构体,就有 (*sk_data_ready)(struct sock *sk,int bytes);的回调,就将节点激活了。 struct sock是在最内层的,不管是创建udp还是tcp,sock直接就会有,所以接受和发送缓冲区对于tcp、udp都是存在的。

回调机制是在 struct sock 结构体中,有函数指针,创建对象时,直接初始化成 epoll 模型里面的一个方法,节点一激活,放到就绪队列中。

4. 代码

events 可以是以下几个宏的集合:

  • EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
  • EPOLLOUT : 表示对应的文件描述符可以写;
  • EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这⾥应该表示有带外数据到来);
  • EPOLLERR : 表示对应的文件描述符发⽣错误;
  • EPOLLHUP : 表示对应的文件描述符被挂断;
  • EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
  • EPOLLONESHOT:只监听⼀次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL红黑树里.
cpp 复制代码
// EpollEchoServer.hpp
#pragma once

#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include "Logger.hpp"
#include "Socket.hpp"

const static int gsize = 64;

class EpollServer
{
public:
    EpollServer(uint16_t port)
    :_listensock(std::make_unique<TcpSocket>()),_epfd(-1)
    {
        _listensock->BuildListenSocketMethod(port);  // 3
        _epfd = epoll_create(123);  // 4
        if(_epfd < 0)
        {
            LOG(LogLevel::FATAL) << "epoll_create failed";
            return;
        }
        LOG(LogLevel::INFO) << "Listen sockfd is: " << _listensock->SockFd()
        << " epoll fd is: " << _epfd;
        // 首先要做的是把唯一的一个listensock添加到epoll模型中!!
        // 用户告诉内核,你要帮我关心哪一个fd上面的哪些事件
        struct epoll_event ev;
        ev.events = EPOLLIN;
        ev.data.fd = _listensock->SockFd();  // ?
        int n = epoll_ctl(_epfd,EPOLL_CTL_ADD,_listensock->SockFd(),&ev);
        (void)n;

    }
    void Start()
    {
        int timeout = 1000; //同poll
        while(true)
        {
            // 1. 可以直接accept吗?不可以!!accept只需要拷贝,不需要等待
            // 内核告诉用户,哪些fd上面的哪些事件就绪了
            int n = epoll_wait(_epfd,revs,gsize,timeout);
            switch(n)
            {
                case 0:
                    LOG(LogLevel::DEBUG) << "time out......";
                    break;
                case -1:
                    LOG(LogLevel::FATAL) << "epoll_wait filed";
                    break;
                default:
                    // Dispatcher();  // 存在就绪事件,就得派发
                    break;
            }
        }
    }
    ~EpollServer()
    { 
        
    }
private:
    std::unique_ptr<Socket> _listensock;
    int _epfd;
    // 就绪事件放在外面
    struct epoll_event revs[gsize]; //revs:return events; 
};

非阻塞轮询:

cpp 复制代码
int timeout = 0; 

阻塞等待:

cpp 复制代码
int timeout = -1; 
cpp 复制代码
#pragma once

#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include "Logger.hpp"
#include "Socket.hpp"
#include "InetAddr.hpp"

const static int gsize = 64;

class EpollServer
{
public:
    EpollServer(uint16_t port)
        : _listensock(std::make_unique<TcpSocket>()), _epfd(-1)
    {
        _listensock->BuildListenSocketMethod(port); // 3
        _epfd = epoll_create(123);                  // 4
        if (_epfd < 0)
        {
            LOG(LogLevel::FATAL) << "epoll_create failed";
            return;
        }
        LOG(LogLevel::INFO) << "Listen sockfd is: " << _listensock->SockFd()
                            << " epoll fd is: " << _epfd;
        // 首先要做的是把唯一的一个listensock添加到epoll模型中!!
        // 用户告诉内核,你要帮我关心哪一个fd上面的哪些事件
        struct epoll_event ev;
        ev.events = EPOLLIN;
        ev.data.fd = _listensock->SockFd(); // ?
        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->SockFd(), &ev);
        (void)n;
    }

    void Dispatcher()
    {
        LOG(LogLevel::INFO) << "有事件就绪了......";
        // 获取新连接
        InetAddr clientaddr;
        int sockfd = _listensock->Accept(&clientaddr);
        if (sockfd > 0)
        {
            LOG(LogLevel::INFO) << "获取一个新连接, fd: " << sockfd
                                << " 客户端地址是:" << clientaddr.ToString();
            // sockfd ,可以对这个新的连接进行读取吗??不能!!连接建立是三次握手完成,三次握手完成并没有说要跟你建立通信
            // 当前套接字上的数据是否就绪,根本就不知道
            // 应该做什么??将新的sockfd添加到 epoll模型中,本质是红黑树中
            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = sockfd;
            int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
            (void)n;
            LOG(LogLevel::INFO) << "添加新连接到 epoll 中: " << sockfd;
        }
    }

    void Start()
    {
        int timeout = -1; // 同poll
        while (true)
        {
            // 1. 可以直接accept吗?不可以!!accept只需要拷贝,不需要等待
            // 内核告诉用户,哪些fd上面的哪些事件就绪了
            int n = epoll_wait(_epfd, revs, gsize, timeout);
            switch (n)
            {
            case 0:
                LOG(LogLevel::DEBUG) << "time out......";
                break;
            case -1:
                LOG(LogLevel::FATAL) << "epoll_wait filed";
                break;
            default:
                Dispatcher(); // 存在就绪事件,就得派发
                break;
            }
        }
    }
    ~EpollServer()
    {
    }

private:
    std::unique_ptr<Socket> _listensock;
    int _epfd;
    // 就绪事件放在外面
    struct epoll_event revs[gsize]; // revs:return events;
};

上层如果没有处理的话,先是阻塞式的等待,只有有一个连接到了,就会一直处于死循环

cpp 复制代码
    void Dispatcher()
    {
        LOG(LogLevel::INFO) << "有事件就绪了......";
        // 获取新连接
        // InetAddr clientaddr;
        // int sockfd = _listensock->Accept(&clientaddr);
        // if (sockfd > 0)
        // {
        //     LOG(LogLevel::INFO) << "获取一个新连接, fd: " << sockfd
        //                         << " 客户端地址是:" << clientaddr.ToString();
        //     // sockfd ,可以对这个新的连接进行读取吗??不能!!连接建立是三次握手完成,三次握手完成并没有说要跟你建立通信
        //     // 当前套接字上的数据是否就绪,根本就不知道
        //     // 应该做什么??将新的sockfd添加到 epoll模型中,本质是红黑树中
        //     struct epoll_event ev;
        //     ev.events = EPOLLIN;
        //     ev.data.fd = sockfd;
        //     int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
        //     (void)n;
        //     LOG(LogLevel::INFO) << "添加新连接到 epoll 中: " << sockfd;
        // }
    }

为什么会出现这样的情况?

epoll_wait 底层只要有就绪事件,如果没有将就绪事件没有取走 or 即便是取走了,但是没有真正的处理了,底层就会一直触发,进而一直看到的就是死循环。

触发的死循环:

cpp 复制代码
    void Start()
    {
        int timeout = -1; // 同poll
        while (true)
        {
            // 1. 可以直接accept吗?不可以!!accept只需要拷贝,不需要等待
            // 内核告诉用户,哪些fd上面的哪些事件就绪了
            int n = epoll_wait(_epfd, revs, gsize, timeout);
            switch (n)
            {
            case 0:
                LOG(LogLevel::DEBUG) << "time out......";
                break;
            case -1:
                LOG(LogLevel::FATAL) << "epoll_wait filed";
                break;
            default:
                Dispatcher(); // 存在就绪事件,就得派发
                break;
            }
        }
    }
cpp 复制代码
#pragma once

#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include "Logger.hpp"
#include "Socket.hpp"
#include "InetAddr.hpp"

const static int gsize = 64;

class EpollServer
{
public:
    EpollServer(uint16_t port)
        : _listensock(std::make_unique<TcpSocket>()), _epfd(-1)
    {
        _listensock->BuildListenSocketMethod(port); // 3
        _epfd = epoll_create(123);                  // 4
        if (_epfd < 0)
        {
            LOG(LogLevel::FATAL) << "epoll_create failed";
            return;
        }
        LOG(LogLevel::INFO) << "Listen sockfd is: " << _listensock->SockFd()
                            << " epoll fd is: " << _epfd;
        // 首先要做的是把唯一的一个listensock添加到epoll模型中!!
        // 用户告诉内核,你要帮我关心哪一个fd上面的哪些事件
        struct epoll_event ev;
        ev.events = EPOLLIN;
        ev.data.fd = _listensock->SockFd(); // 方便后续处理!!
        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->SockFd(), &ev);
        (void)n;
    }

    void Accepter()
    {
        // 获取新连接
        InetAddr clientaddr;
        int sockfd = _listensock->Accept(&clientaddr);
        if (sockfd > 0)
        {
            LOG(LogLevel::INFO) << "获取一个新连接, fd: " << sockfd
                                << " 客户端地址是:" << clientaddr.ToString();
            // sockfd ,可以对这个新的连接进行读取吗??不能!!连接建立是三次握手完成,三次握手完成并没有说要跟你建立通信
            // 当前套接字上的数据是否就绪,根本就不知道
            // 应该做什么??将新的sockfd添加到 epoll模型中,本质是红黑树中
            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = sockfd;
            int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
            (void)n;
            LOG(LogLevel::INFO) << "添加新连接到 epoll 中: " << sockfd;
        }
    }

    void Recver(int sockfd)
    {
        char buffer[1024]; // bug ! --- 读取的时候,不能保证能将报文读取完,也不能保证读取的就是一个完整的报文
                           // 必须结合协议,序列化、反序列化
        ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            // 成功
            buffer[n] = 0;
            std::cout << "Client Say@ " << buffer;
            std::string echo_string = "echo server# ";
            echo_string += buffer;

            // 写?没错,但是不完善
            send(sockfd, echo_string.c_str(), echo_string.size(), 0);
        }
        else if (n == 0)
        {
            // 对端将连接关掉
            LOG(LogLevel::INFO) << "client quit, me too, fd is : " << sockfd;
            // 坑:epoll_ctl EPOLL_CTL_DEL:不能对非法fd进行操作
            int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            (void)n;
            close(sockfd);
        }
        else
        {
            // 失败
            LOG(LogLevel::INFO) << "recv error, fd is : " << sockfd;
            int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            (void)n;
            close(sockfd);
        }
    }

    void Dispatcher(int num)
    {
        LOG(LogLevel::INFO) << "有事件就绪了......";
        for (int i = 0; i < num; i++)
        {
            int fd = _revs[i].data.fd;
            uint32_t events = _revs[i].events;

            if (events & EPOLLIN)
            {
                // 1. listensockfd
                if (fd == _listensock->SockFd())
                {
                    Accepter();
                }
                else
                {
                    // 2.普通的sockfd
                    Recver(fd);
                }
            }
            // if(events & EPOLLOUT)
            // {
            //     // TODO
            // }
        }
    }

    void Start()
    {
        int timeout = -1; // 同poll
        while (true)
        {
            // 1. 可以直接accept吗?不可以!!accept只需要拷贝,不需要等待
            // 内核告诉用户,哪些fd上面的哪些事件就绪了
            // 2. epoll_wait 就绪的fd,就绪的事件可是多种多样的!
            int n = epoll_wait(_epfd, _revs, gsize, timeout);
            switch (n)
            {
            case 0:
                LOG(LogLevel::DEBUG) << "time out......";
                break;
            case -1:
                LOG(LogLevel::FATAL) << "epoll_wait filed";
                break;
            default:
                Dispatcher(n); // 存在就绪事件,就得派发
                break;
            }
        }
    }
    ~EpollServer()
    {
    }

private:
    std::unique_ptr<Socket> _listensock;
    int _epfd;
    // 就绪事件放在外面
    struct epoll_event _revs[gsize]; // revs:return events;
};

5. epoll 工作模式

LT 和 ET 是就绪事件的通知机制。

5.1 LT:水平触发工作模式(Level Triggered)

epoll默认状态下就是LT工作模式

  • 当epoll检测到socket上事件就绪的时候, 可以不⽴刻进⾏处理. 或者只处理⼀部分
  • 如上⾯的例⼦, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时,epoll_wait 仍然会⽴刻返回并通知socket读事件就绪.
  • 直到缓冲区上所有的数据都被处理完, epoll_wait 才不会⽴刻返回.
  • 支持阻塞读写和非阻塞读写

5.2 ET:边缘触发工作模式(Edge Triggered)

如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进⼊ET⼯作模式

  • 当epoll检测到socket上事件就绪时, 必须⽴刻处理.
  • 如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第⼆次调⽤ epoll_wait 的时候, epoll_wait 不会再返回了.
  • 也就是说, ET模式下, ⽂件描述符上的事件就绪后, 只有⼀次处理机会
  • ET的性能比LT性能更⾼( epoll_wait 返回的次数少了很多). Nginx默认采⽤ET模式使⽤epoll.
  • 只支持非阻塞的读写

例子,区分上面的两种工作模式:

小帅,在某购物平台上买了5件商品,到了菜鸟驿站,有两个快递员,张三,李四,这天下午,张三将其中3个快递,其余的快递没看到,张三送到小帅的宿舍楼下,小帅在宿舍里面打游戏,张三打电话让小帅下来取快递,但是小帅过来好久都没下去,张三又打电话,让他下来,就这样一直打电话,小帅就下去了,但是其中有一个是大件,先将其余两个取了上去,小帅忘了下面还有了一个快递,就呆在宿舍里面了。此时李四来送小帅剩余的2个快递了,看见张三还在小帅的宿舍楼下等,就将剩余的两个快递给张三,让张三一起给小帅,张三就又给小帅打电话说,你又有两个快递了,快下来取,小帅才去取快递。张三车里只要一直都有小帅的快递,就会一直给小帅打电话。这种就被称为 LT策略,水平触发工作模式

第二天,又有小帅的5个快递了,但是今天的快递员是李四,李四将所有人的快递包括小帅的3个快递拉到小帅的宿舍楼下,打电话给小帅说,小帅快下来取快递,我只打一次电话,你不下来我就走了,小帅听了立马就下去取快递了,看见有一个大件的,就先将大的拿了上去,给李四说,我等下下来取剩下的两个,李四鸟都不鸟他,上去之后,又被舍友拉着打游戏去了,李四将所有人的快递派发完了,此时张三将小帅的剩下的两个快递给李四了,李四再一次打电话,说,小帅你的快递又有两个快下来取,不来的话我就要走了,小帅就下去了,将剩下的所有快递取完了。李四的策略是:通知你,你就要下来,只通知你一次 --- ET模式。从无到有,从有到多,才会通知你一次!!

其实,在通知的过程就是 epoll_wait 的过程,在通知上层哪些事件就绪了!!!

问题1:谁派发快递的效率高一些???李四,为什么??

通知效率高,本质是无效通知少!!!

问题2:你更喜欢谁派发快递的方式???---- 张三

在示波器中,一直处于峰值的横线被称为LT模式,从无到有 或 从右到无变化的称为 ET模式(了解)

5.3 LT 和 ET 的设计??

LT :一个文件描述符节点被激活,通知上层第一次,上层一直再取但是一直没有取完,所以不能将这个文件描述符从就绪队列中移走。

ET :第一次底层有数据,5号文件描述符被激活了,epoll_wait 读取就绪事件,5号节点一旦被epoll_wait 节点拿走,5号节点就会在就绪队列中立即被移除掉,就只在红黑树中存在,从激活状态恢复到非激活状态,当数据新增发生变化,再次被激活。

LT 和 ET 本质就是衡量的是就绪事件,就绪节点在就绪队列中的存活时长的问题。

读写并不会影响红黑树节点的任何状态,只有 epoll_ctl () 对应的add、del、mod 才会修改红黑树,读写只会影响就绪队列。

5.4 LT vs ET

两种模式相比,谁的效率更高??? ---- ET的通知效率高!!

IO方面呢?(通知只是IO方面的一个环节) --- 面试(为什么在ET模式下,将文件描述符设置为非阻塞模式)

ET -> 从无到有,从有到多 -> 才会通知我一次 -> 要求程序员必须一次将本轮数据全部读取完毕 -> 如何保证你把TCP缓冲区本次的数据全部读取完毕!!---- 循环读 - > 最后一次读取完毕,我们往往还要读 -> 因为fd阻塞,recv就会被卡住!!!此时服务器就会被挂起!!!

所以ET模式下,必须把对应的文件描述符设置为非阻塞!!!是为了读取完本轮数据!!!

为什么ET模式下IO会更高呢??

ET 本质是倒逼程序员,按照它的要求把数据读取完毕!上层就会将数据尽快取走,此时ACK应答,就会给发送方通知一个更大的win窗口!!!影响对方的滑动窗口大小!!变得更大,IO效率就更高了!

历史问题:PSH,要求对方内核把数据尽快交给上层!!!交给上层是由用户recv才会把数据读上去,内核能做的就是让对应的fd事件就绪!!!不是说一有数据就调用系统调用,OS会在TCP缓冲区中设置一个高低水位线的概念,低于低水位线,TCP不通知上层,epoll也不通知,超过了才会通知,这样让上层调用一次系统调用拷贝更多的数据,从而减少系统调用次数。而PSH的本质就是:无视高低水位线,只要有数据,立马通知上层。

ET 做的工作 LT 也能做,循环读,将fd设置为非阻塞,但是 LT 本质: 不能倒逼程序员!!!

所以之前的select、poll,获取了一个连接,到来了一个新的事件,但不进行处理,为什么回答while死循环:

因为 epoll 的工作模式默认是 LT !底层事件没有做处理,底层就一直通知,一直返回。select 和 poll 没有工作模式,就变相的是 LT 的工作模式。

相关推荐
Soari41 分钟前
TSN网络之工业以太网协议测试
网络
s_w.h44 分钟前
【 计网 】序列化与反序列化
linux·服务器·网络·算法·bash
物联通信量讯说1 小时前
5G会取代4G吗?物联网设备出海正在走向多网络融合
网络·物联网·5g
祖力551 小时前
网络编程:IO多路复用(select、poll、epoll)
linux·服务器·网络
数字智核1 小时前
2026昆山工厂空压机突然停机怎么办?找谁抢修
服务器·网络·数据库
迷路爸爸1801 小时前
在 Ubuntu / Linux 上轻量安装 LaTeX:TinyTeX 使用记录
linux·运维·ubuntu
pt10431 小时前
网络自动化Python课程:利用Cisco NSO编排网络服务
运维·网络·自动化
tedcloud1231 小时前
OpenLogi 怎么搭建?用 Rust 打造一个轻量的 Logitech 外设管理工具
linux·运维·服务器·开发语言·后端·rust·开源
AAA代码批发商1 小时前
Days 39 Linux C 开发之 SQLite 数据库完整学习笔记
linux·c语言·数据库