Linux中进程间通信--匿名管道和命名管道

        本篇将会进入 Linux 进程中进程间通信,本篇简要的介绍了 Linux 中进程为什么需要通信,进程间通信的常用方式。然后详细的介绍了 Linux 进程间的管道通信方式,管道通信分为匿名管道和命名管道,本篇分别介绍了其实现的原理,以及使用 Linux 中的系统调用,用代码实现了这两种通信方式。

        还详细的介绍了关于管道通信的五种特征和四种情况。

目录

Linux中进程间通信

1. 进程之间为什么需要通信

2. 进程之间如何进行通信

3. 进程间通信常用方式

匿名管道

1. 匿名管道实现原理

2. 匿名管道的实现规定

3. 模拟管道进程通信

5. 匿名管道在命令行中的方式

6. 基于匿名管道的进程池

6. 该代码容易出现的 bug

管道通信的特征与情况

1. 管道通信的五种特征

2. 管道通信的四种情况

命名管道 

1. 命名管道实现原理

2. 管道文件的特点

3. 使用命名管道通信

Linux中进程间通信

1. 进程之间为什么需要通信

        Linux 中的各种进程之间是相互独立的,但是进程和进程之间也是需要相互协调工作的,所以进程之间就会存在相互通信,一切协调完成工作。

        在用于通信的数据中,数据是有类别的,比如:用于通知就绪的、单纯传递给我的数据、控制相关的信息等等。

2. 进程之间如何进行通信

        首先,对于进程之间的通信,成本会比较高。因为进程之间相互独立,也就意味着进程之间的数据也是相互独立的,通信的本质就是信息之间的传递(数据之间的传递),所以进程之间并不能直接的进行通信(传递数据)。

        那么对于一个特殊的例子:父子进程之间算不算天然具有通信的能力呢?从严格意义上来说着并不算一种通信方式,虽然子进程可以继承父进程的数据和代码,但是子进程和父进程之间并不能一直交换数据(写时拷贝会将数据修改),所以并不能算得上是通信。

        所以对于进程间通信的前提:让不同的进程,看到同一份操作系统级别的资源,也就是同一段内存。如下:

        所以对于以上进程之间的通信:

        a. 一定是某个进程先需要通信,然后让操作系统创建一个共享资源;

        b. 操作系统必须提供很多的系统调用,进程需要使用系统调用让操作系统申请资源。

        c. 进程间通信会存在不同的种类,所以操作系统共享的资源会不同,系统调用接口也会不同。

3. 进程间通信常用方式

        进程间通信常用的两套标准为 system V 和 Posix 标准。对于这两个标准,只做介绍并不详解。

        对于 system V 标准来说,一共存在三种方式:消息队列、贡献内存、信号量。三种方式。但是在这些方式出来之前,还存在一种更为简单的通信方式:管道(直接复用内核代码进行直接通信)

        管道又分为命名管道匿名管道

匿名管道

1. 匿名管道实现原理

        我们首先先介绍关于匿名管道的实现原理,如下图:

        如上图:对于存在磁盘中的一个文件,父进程首先使用读方式和写方式分别打开该文件,然后就会依次创建出文件结构体以及文件的各种缓冲区,接着将文件结构体中的各种指针指向这些缓冲区,然后将文件结构体链接到父进程的文件描述符表中。

        然后父进程创建出它的子进程,子进程会开辟出空间,然后继承父进程的代码和数据,但是对于父进程的文件系统中的数据并不会创建出独立的空间用于存放,所以子进程创建出来的文件描述符表同样会指向之前父进程打开的文件结构体

        通过以上操作之后,子进程和父进程之间也就存在指向同一个文件了,也就是满足了进程之间通信的前提:不同进程可以看见同一块内存空间、同一份文件缓冲区(这个文件被我们称为管道文件)(其实这也是为什么每个进程会默认打开三个标准输入输出标准错误流(0、1、2)的原因:因为每个创建的子进程都是 bash 创建的,bash 是打开这三个流的,所以子进程会进程 bash 的文件描述符表,同样指向 0、1、2)。

        那么为什么我们子进程使用 close 关闭文件的时候,父进程也还可以使用呢?

        这是因为关于文件结构体 struct file 会存在着内存级的引用计数,几个进程指向该文件就有几个引用计数,当引用计数变为 0 的时候才会将文件关闭。

2. 匿名管道的实现规定

        对于管道而言,我们规定其只能单向通信,也就是意味只有两种通信方式:父进程发,子进程收 或者 子进程发、父进程收。所以我们在创建出了管道文件之后,我们需要将文件描述符表对应的管道文件关闭,比如父进程发消息、子进程收消息时,父进程关闭读方式的管道文件、子进程关闭写方式的管道文件。

        我们在以上创建出管道文件之后,我们只会用其进行进程之间的通信,也就是不需要将通信的内容刷新到磁盘中,所以我们可以重新设计出通信接口,只设计出内存级的管道文件,不用访问磁盘,如下:

        将其简化之后,就是下图:

        关于以上的规定,匿名管道只允许单向进行通信,那假设我们要进行双向通信呢,我们只需要将创建出两个管道文件就可以进行双向通信了

        为什么需要规定是单向通信呢?这是因为在设计管道通信时就是想要复用代码进行通信,也就是尽可能简单的进行通信,假若设计成双向的通信方式,则需要在管道内分别区分出来自不同进程的信息,这就会大大增加通信的复杂程度。

3. 模拟管道进程通信

        接下来我们将使用代码来模拟管道间的通信,其中有一个很重要的通信接口,如下:

int pipe(int pipefd[2]);

        该接口的底层实现就是调用了文件打开函数 open,不过我们并不需要提供文件路径和文件名,因为生成的是匿名管道文件,pipefd[0] 表示的是文件读端、pipefd[1] 表示的是文件写端,该接口的返回值若为0表示管道文件设置成功,非零则表示设置失败。

        现在我们通过如下代码来查看子进程向父进程发送消息,父进程接收信息之后将信息打印出来,如下代码:

#include <iostream>
#include <unistd.h>
#include <cstring>
#include <cerrno>
#include <sys/wait.h>
#include <stdlib.h>
#include <sys/types.h>
#include <string>

const int size = 1024;

std::string GetOtherMessage() {
    static int cnt = 0;
    std::string messageid = std::to_string(cnt);
    pid_t rid = getpid();
    std::string child_id = std::to_string(rid);
    std::string retMessage = "messageid: ";
    retMessage += messageid;
    retMessage += " my pid is ";
    retMessage += child_id;
    cnt++;
    return retMessage;
}

void SubProcessWrite(pid_t wfd) {
    std::string message = "father, I am your son! ";
    while (true) {
        std::string info = message + GetOtherMessage();
        write(wfd, info.c_str(), info.size());
        sleep(1);
    }
}

void FatherProcessRead(pid_t rfd) {
    char inbuff[size];
    while (true) {
        int n = read(rfd, inbuff, sizeof(inbuff) - 1);
        if (n > 0) {
            inbuff[n] = '\0';
            std::cout << "father get message: " << inbuff << std::endl;
        }
    }
}

int main () {
    // 创建管道
    int pipefd[2];
    int n = pipe(pipefd);
    if (n != 0) {
        std::cout << "errno: " << errno << " errstring: " << strerror(errno) << std::endl;
        return 1;
    }
    // 创建出子进程
    pid_t id = fork();
    if (id == 0) {
        // 使用子进程进行写,关闭读端
        close(pipefd[0]);
        // 子进程发送信息
        SubProcessWrite(pipefd[1]);
        close(pipefd[1]);
        exit(0);

    }
    // 父进程进行读,关闭写端
    close(pipefd[1]);
    // 父进程接收信息
    FatherProcessRead(pipefd[0]);
    close(pipefd[0]);
    // 等待回收子进程
    int status;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0) 
        std::cout << "wait child process sucess!" << std::endl;
    else
        std::cout << "wait child process fail!" << std::endl;
    return 0;
}

        运行结果如下:

        由上可得,父进程会逐一从管道中读出子进程写入管道的数据。

5. 匿名管道在命令行中的方式

        在命令行中直接使用匿名管道,我们只需要在我们需要执行的命令间加上 " | ",就表示使用管道,如下:

        使用管道同时执行三个指令,这三个指令是同时进行的。

6. 基于匿名管道的进程池

        现在我们将基于以上的知识,使用代码来实现一份基于匿名管道通信的一个进程池项目。该进程池实现的功能为:父进程为任务派送方,然后生成若干子进程,然后将任务均匀的分发至子进程,让子进程分别执行派发下来的任务

        我是在 Linux 下完成的,.cc 后缀的也是 C++ 代码文件,.hpp 也是 C++ 代码的头文件,另外还给出了我的 makefile 文件。

        代码如下:

Task.hpp

#pragma once
#include <iostream>
#include <cstdlib>

#define TASKNNUM 3

typedef void (*task_t)();
task_t tasks[TASKNNUM];

void Print() {
    std::cout << "I am print task" << std::endl;
}

void DownLoad() {
    std::cout << "I am a dowmload task" << std::endl;
}

void Flush() {
    std::cout << "I am a flush task" << std::endl;
}

void LoadTask() {
    srand(time(0) ^ getpid());
    tasks[1] = DownLoad;
    tasks[2] = Flush;
    tasks[0] = Print;

}

size_t SelectTask() {
    int selectnum = rand() % TASKNNUM;
    return selectnum;
}

void SubReadCommand(std::string name) {
    while (true) {
        int command = 1;
        int n = read(0, &command, sizeof(int));
        if (n == sizeof(int)) {
            // 执行对应的程序
            std::cout << "I am " << name << std::endl;
            size_t taskindex = command;
            tasks[taskindex]();
        } else if (n == 0) {
            // 读到0个数,说明读端已经关闭
            std::cout << "sub process : " << getpid() << " quit" << std::endl;
            break;
        }
    }
}

ProcessPool.cc

#include <iostream>
#include <vector>
#include <string>
#include <unistd.h>
#include <cstring>
#include <cerrno>
#include <sys/wait.h>
#include "Task.hpp"

const int size = 256;

class Channel {
public:
    Channel(const std::string& name, pid_t subprocessid, size_t wfd)
        : _name(name)
        , _subprocessid(subprocessid)
        , _wfd(wfd)
    {} 

    pid_t GetSubProcessId() {
        return _subprocessid;
    }

    std::string GetSubName() {
        return _name;
    }

    size_t GetSubWfd() {
        return _wfd;
    }

    // 等待子进程退出
    void SubWait() {
        int status;
        pid_t rid = waitpid(_subprocessid, &status, 0);
        if (rid > 0)
            std::cout << _name << " wait success!" << " exit signal: " << (status & 0x7F) << std::endl;
        else
            std::cout << _name << " wait fail!" << " exit signal: " << (status & 0x7F) << std::endl;
    }

    // 关闭写端
    void CloseWfd() {
        close(_wfd);
        // std::cout << _name << " wfd close success!" << std::endl;
    }

private:
    std::string _name;
    pid_t _subprocessid;
    size_t _wfd;
};



void FatherCreateWorkerProcess(std::vector<Channel>* channels, int num) {
    for (int i = 0; i < num; i++) {
        int pipefd[2];
        int n = pipe(pipefd);
        if (n == -1) {
            std::cerr << "Create pipe fail! " << "error string: " << strerror(errno) << std::endl;
            exit(1);
        }
        // 使用fork创建出子进程
        // 创建出子进程之后,让子进程关闭掉写端,父进程关闭掉读端
        std::string name("worker-");
        name += std::to_string(i);

        pid_t id = fork();
        if (id == 0) {
            if (!channels->empty()) {
                for (auto& channel : *channels)
                    channel.CloseWfd();
            }
            close(pipefd[1]);
            // 子进程一直阻塞等待读入消息
            // 重定向子进程
            dup2(pipefd[0], 0);
            SubReadCommand(name);     
            close(pipefd[0]);       
            exit(0);    
        }
        close(pipefd[0]);
        Channel channel(name, id, pipefd[1]);
        channels->push_back(channel);
    }
}
    
size_t SelectSubProcess(int num) {
    static size_t cnt = 0;
    int ret = cnt;
    cnt++;
    cnt %= num;
    return ret;
}

void ControlProcessOnce(std::vector<Channel>& channels, int num) {
    size_t taskindex = SelectTask();
    // 然后让对应的子进程执行对应的程序
    size_t subindex = SelectSubProcess(num);
    write(channels[subindex].GetSubWfd(), &taskindex, sizeof(taskindex));
    std::cout << std::endl;
    sleep(1);
}

void ControlProcess(std::vector<Channel>& channels, int num, int times = -1) {
    // 让父进程向管道中输入信息
    // 先选中要执行的任务
    if (times > 0) 
        while (times--)
            ControlProcessOnce(channels, num);
    else
        while (true)
            ControlProcessOnce(channels, num);
}

void ClosePipeandSub(std::vector<Channel>& channels) {
    // 逐一关闭对应的,关闭了写端
    for (auto& channel : channels)
        channel.CloseWfd();
    for (auto& channel : channels)
        channel.SubWait();
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cout << "the parameter of command line is not equal two, please input again" << std::endl;
        return 1;
    }
    // 加载任务
    LoadTask();
    // 创建子进程
    std::vector<Channel> channels;
    int channelnums = std::stoi(argv[1]);
    FatherCreateWorkerProcess(&channels, channelnums);

    // for (auto& channel : channels)
    //     std::cout << channel.GetSubName() << std::endl;

    // 控制子进程
    ControlProcess(channels, channelnums, 5);
    // 退出,关闭对饮的管道和子进程
    ClosePipeandSub(channels);
    return 0;
}

makefile

processpool:ProcessPool.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f processpool
6. 该代码容易出现的 bug

        实现该代码容易出现的一个问题为,开辟出来的匿名管道可能有着多个进程指向同一个管道,如下:

        当我们 fork 出第一个子进程创建出管道的时候,我们可以建立出一个一对一的匿名管道通信,当我们继续进行 fork 子进程创建出管道的时候,父进程和子进程仍然会创建一个单独的匿名管道,然后分别关闭对应的读端和写端,但是子进程会继承父进程的数据,也就是会继承指向第一个创建出的管道的文件描述符。以此类推,每创建出一个子进程,指向前面管道的文件描述符都会加一。所以我们在创建子进程的时候就应该关闭这些指向匿名管道的文件描述符,如下:

void FatherCreateWorkerProcess(std::vector<Channel>* channels, int num) {
    for (int i = 0; i < num; i++) {
        int pipefd[2];
        int n = pipe(pipefd);
        if (n == -1) {
            std::cerr << "Create pipe fail! " << "error string: " << strerror(errno) << std::endl;
            exit(1);
        }
        // 使用fork创建出子进程
        // 创建出子进程之后,让子进程关闭掉写端,父进程关闭掉读端
        std::string name("worker-");
        name += std::to_string(i);

        pid_t id = fork();
        if (id == 0) {
            if (!channels->empty()) {
                for (auto& channel : *channels)
                    channel.CloseWfd();
            }
            close(pipefd[1]);
            // 子进程一直阻塞等待读入消息
            // 重定向子进程
            dup2(pipefd[0], 0);
            SubReadCommand(name);     
            close(pipefd[0]);       
            exit(0);    
        }
        close(pipefd[0]);
        Channel channel(name, id, pipefd[1]);
        channels->push_back(channel);
    }
}

        我们只需要在除创建第一个子进程的时候,将指向其他管道给关闭就可以了。

管道通信的特征与情况

1. 管道通信的五种特征

        对于管道一共存在 5 种特征和 4 种情况。

        管道的 5 种特征

        1. 对于匿名管道:只用来进行具有血缘关系的进程之间进行通信,比如兄弟进程、爷孙进程、父子进程,常用于父子进程之间进行通信。因为具有血缘关系的进程可以建立匿名管道

        2. 管道内部,自带进程之间同步的机制,多执行流执行代码的时候,具有明显的顺序性。比如子进程写一个数据,父进程读一个数据;子进程不写数据,父进程就阻塞不读数据。这样一种同步的过程。

        3. 管道文件的生命周期是随进程的。当进程打开该文件并且没有主动关闭,只要指向该文件的进程都关闭了,该文件也会被关闭,管道文件亦是如此。

        4.. 管道文件在通信的时候,是面向字节流的。管道中的读端和写端分别在读和写的时候可以看成是流动的,不过读端和写端他们之间的读取次数和写入次数不一定是一一匹配的,写端可以一次写很多,读端分批读出,写端也可以一次写一点,读端一次读出来。

        5. 管道的通信模式,是一种特殊的半双工模式。半双工的通信模式就是一次只能有一方发消息另一方收消息,也可以反过来。但是管道的通信方式,发消息和收消息方已经确定。

2. 管道通信的四种情况

         管道的四种情况

        1. 管道内部为空且写端的文件描述符并没有关闭,还会写但是还没写。这种情况为:读取条件不具备读进程会被阻塞,读进程会等待读条件具备(也就是写端写入的时候)。所 以这个时候读进程就会被操作系统从运行队列中拿出来放到管道的等待队列中,直到管道中有内容的时候,才会将其唤醒。

        2. 如果管道被写满且读端的文件描述符没有关闭,还能读但是没有读,这种情况为写条件不具备,写进程将会被阻塞,因为在写下去将会覆盖原来的数据,只有当数据被读端读出之后,写段才可以继续写下去。

        3. 读端一直在读,但是写端关闭了写文件描述符,则读端 read 的返回值将会读到0,表示读到了文件末尾。

        4. 读端关闭,写端一直在写入的情况,这种情况为坏管道的情况。一个管道只会有一个读端一个写端,当读端关闭之后,写端在继续写下去就没有意义了,操作系统不会允许这样浪费时间浪费空间的事情发生,因为会无缘无故的浪费资源,所以将会被操作系统认定为一种异常情况,操作系统将会给目标进程发送 13号 SIGPIPE 信号,杀掉对应的写端。

另外,对于写端和写入信息被读取的时候还有两个特点,如下:

        当写端一次写入的数据小于 Linux 中规定的 PIPE_BUF 的时候,那么这个信息是原子的,也就是写入这份数据之后,读端并不能将数据读走。但是当大于 PIPE_BUF的时候,那么有可能写到一半就被读端读走了。

        PIPE_BUF 在 Linux 中一半是 4096 个字节。

命名管道 

1. 命名管道实现原理

        匿名管道用于存在血缘关系(父子、兄弟、爷孙)进程之间的通信,那么命名管道就是用于两个毫不相关之间的进程之间的管道通信。通信原理如下:

        如上图所示,两个毫不相关的进程之间通信会打开同一个文件(每个文件都有唯一的路径),命名管道的通信方式和匿名管道一样,只能一端写一端读。由于两端实时通信之间并不需要将数据刷新到磁盘,所以我们打开这个文件是一个特殊文件,并不需要将数据刷新到磁盘中,这种文件为管道文件

2. 管道文件的特点

        我们创建出管道文件可以使用命令 mkfifo,如下:

mkfifo - make FIFOs (named pipes)

        当写端向管道文件写入消息的之后,假若没有读端将信息读出来,那么写端将会一直阻塞,如下:

        这种情况也符合管道的一种情况,当写端在写信息,但是读端不读且文件描述符未关闭,这就会导致写端处于一种阻塞的状态。

        假若我们将在管道进行写入的时候,将读端的文件描述符给关闭了,那么就会导致我们的 shell 出问题,被强制退出,这是因为当我们将读端的文件描述符给关闭,但写端未关闭,这个时候就会给写端发送 SIGPIPE 信号,然后就会将写端给强制杀掉,但是这个时候写端是由 bash 打开的,所以就会导致我们的 shell 出问题。

        所以不管是命名管道还是匿名管道都遵循管道的四情况和五特征

        管道文件的大小不会变化,不管是否向管道文件中写入,都不会有变化。因为通信双方写入到文件缓冲区之后并不会将信息刷新到磁盘中。

3. 使用命名管道通信

        使用命名管道通信,仍然有一个很重要的系统调用,还是 mkfifo,不过这是在代码级别的,如下:

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);

        对于返回值:创建成功返回 0,创建失败返回 -1。

        代码如下:

NamedPipe.hpp:

#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <string>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>

#define NPCreater 0
#define NPUser    1
#define NPMode 0666
#define NPRead  O_RDONLY
#define NPWrite O_WRONLY
#define DEFAULT_FDX -1
#define NPReadSize 1024

const std::string ComPath = "./myfifo";


class NamedPipe {
private:
    std::string GetName() {
        if (_who == NPCreater)
            return "Creater";
        else if (_who == NPUser)
            return "User";
        else
            return "None";
    }

    // 以不同的方式打开对应的文件
    int OpenNamedPipe(mode_t mode) {
        int fd = open(_path.c_str(), mode);
        if (fd == -1) {
            std::cout << "open file fail" << " the reason is " << strerror(errno) << std::endl;
        }
        return fd;
    }
public:
    NamedPipe(const std::string& path, size_t who)
        : _path(path), _who(who), _fd(DEFAULT_FDX)
    {
        // 让服务器端读,让客户端写,服务器创建出对应的管道文件
        if (who == NPCreater) {
            int n = mkfifo(_path.c_str(), NPMode);
            if (n < 0) {
                std::cout << "create named pipe fail!" << " the reason is " << strerror(errno) << std::endl;
            }
            _fd = OpenNamedPipe(NPRead);
            std::cout << GetName() << " create the named pipe" << std::endl;

        } else {
            _fd = OpenNamedPipe(NPWrite);   
        }
    }

    int SomeoneUseToRead(std::string* out) {
        char inbuff[NPReadSize];
        int n = read(_fd, inbuff, sizeof(inbuff) - 1);
        if (n == -1) {
            std::cout << "read failed" << " the reason is " << strerror(errno) << std::endl;
        } 
        inbuff[n] = '\0';
        *out = inbuff;
        return n;
    }

    void SomeoneUseForWrite(const std::string& info) {
        int n = write(_fd, info.c_str(), info.size());
        if (n == -1) {
            std::cout << "write failed" << " the reason is " << strerror(errno) << std::endl;
        }
    }

    ~NamedPipe() {
        if (_who == NPCreater) {
            // 让创建者删除对应的管道文件
            int n = unlink(_path.c_str());
            if (n < 0) 
                std::cout << "remove the named pipe fail!" << " the reason is " << strerror(errno) << std::endl;    
        }
        std::cout << GetName() << " unlink the named pipe file " << std::endl;
        if (_fd != DEFAULT_FDX) {
            close(_fd);
        }
    }
private:
    std::string _path;
    size_t _who;
    int _fd;
};

server.cc:

#include "NamedPipe.hpp"

int main() {
    NamedPipe fifo(ComPath, NPCreater);
    // 让服务器不断地读
    while (true) {
        std::string message;
        int n = fifo.SomeoneUseToRead(&message);
        if (n == 0) {
            std::cout << "Client quit... Server too" << std::endl;
            break;
        }
        std::cout << "Client Say > " << message << std::endl;
        sleep(1);
    }
    return 0;
}

client.cc:

#include "NamedPipe.hpp"

std::string GetInfo() {
    static int cnt = 0;
    std::string message = "Hello, I am client";
    message += std::to_string(cnt);
    pid_t id = getpid();
    message += " my pid is ";
    message += std::to_string(id);
    cnt++;
    return message;
}

int main() {
    NamedPipe fifo(ComPath, NPUser);

    while (true) {
        std::string info;
        std::cout << "Please Enter > ";
        std::cin >> info;
        fifo.SomeoneUseForWrite(info);
        sleep(1);
    }

    return 0;
}

        代码测试如下:

        由上的测试我们可以得出,对于读端而言,当我们打开文件,但是写端还没有来,进程会阻塞在 open 调用阶段,只有当写端也打开文件之后,读端才会打开文件。这种机制实际上是一种进程同步

相关推荐

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-07-20 10:06:08       52 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-20 10:06:08       54 阅读
  3. 在Django里面运行非项目文件

    2024-07-20 10:06:08       45 阅读
  4. Python语言-面向对象

    2024-07-20 10:06:08       55 阅读

热门阅读

  1. 百度自动驾驶apollo源码解读12:线程池

    2024-07-20 10:06:08       19 阅读
  2. 网络协议-SOTP 协议格式

    2024-07-20 10:06:08       18 阅读
  3. CSS基础到进阶:掌握网页布局的艺术

    2024-07-20 10:06:08       16 阅读
  4. Emacs的插件生态系统

    2024-07-20 10:06:08       18 阅读
  5. ES6 正则的扩展(十九)

    2024-07-20 10:06:08       19 阅读
  6. golang中实现LRU-K算法(附带单元测试)

    2024-07-20 10:06:08       19 阅读
  7. 23年阿里淘天笔试题 | 卡码网模拟

    2024-07-20 10:06:08       16 阅读