libuv进程通信与管道描述符

libuv 提供了大量的子进程管理,抽象了平台差异并允许使用流或命名管道与子进程进行通信。Unix 中的一个常见习惯是每个进程只做一件事,并且把它做好。在这种情况下,一个进程通常会使用多个子进程来完成任务(类似于在 shell 中使用管道)。与具有线程和共享内存的多进程模型相比,具有消息的多进程模型也可能更容易推理。

对基于事件的程序的常见限制是它们无法利用现代计算机中的多核。在多线程程序中,内核可以进行调度,将不同的线程分配给不同的内核,从而提高性能。但事件循环只有一个线程。解决方法可以是启动多个进程,每个进程运行一个事件循环,并且每个进程被分配给一个单独的 CPU 核心。

libuv进程通信与管道描述符_第1张图片

生成子进程

最简单的情况是当您只想启动一个进程并知道它何时退出时。这是使用 uv_spawn 实现的。

int main() {
    loop = uv_default_loop();
    char* args[3];
    args[0] = "mkdir";
    args[1] = "test-dir";
    args[2] = NULL;

    options.exit_cb = on_exit;
    options.file = "mkdir";
    options.args = args;

    int r;
    if ((r = uv_spawn(loop, &child_req, &options))) {
        fprintf(stderr, "%s\n", uv_strerror(r));
        return 1;
    } else {
        fprintf(stderr, "Launched process with ID %d\n", child_req.pid);
    }

    return uv_run(loop, UV_RUN_DEFAULT);
}

options 隐式地用零初始化,因为它是一个全局变量。如果将 options 更改为局部变量,请记住将其初始化以清空所有未使用的字段:

uv_process_options_t options = {0};

uv_process_t 结构仅充当句柄,所有选项均通过 uv_process_options_t 设置。要简单地启动进程,您只需设置 fileargs 字段。 file 是要执行的程序。由于 uv_spawn 在内部使用 execvp,因此无需提供完整路径。最后,根据底层约定,参数数组必须比参数数量大 1,最后一个元素为 NULL

调用 uv_spawn 后, uv_process_t.pid 将包含子进程的进程 ID。将使用退出状态和导致退出的信号类型来调用退出回调。请注意,不要在退出回调之前调用 uv_close ,重要!。

void on_exit(uv_process_t *req, int64_t exit_status, int term_signal) {
    fprintf(stderr, "Process exited with status %lld, signal %d\n", exit_status, term_signal);
    uv_close((uv_handle_t*)req, NULL);
}

进程退出后需要关闭进程观察器。

改变进程参数

在子进程启动之前,您可以使用 uv_process_options_t 中的字段控制执行环境。

libuv进程通信与管道描述符_第2张图片

改变执行目录

uv_process_options_t.cwd 设置为相应的目录。

设置环境变量

uv_process_options_t.env 是一个以 null 结尾的字符串数组,每个 VAR=VALUE 形式用于设置进程的环境变量。将其设置为 NULL 以从父(此)进程继承环境。

选项与标志

uv_process_options_t.flags 设置为以下标志的按位或,可以修改子进程的行为:

  1. UV_PROCESS_SETUID - 将子进程的执行用户 ID 设置为 uv_process_options_t.uid

  2. UV_PROCESS_SETGID - 将子级的执行组 ID 设置为 uv_process_options_t.gid

    仅 Unix 支持更改 UID/GIDuv_spawn 在 Windows 上将失败,并显示 UV_ENOTSUP

  3. UV_PROCESS_WINDOWS_VERBATIM_ARGUMENTS - Windows 上不会引用或转义 uv_process_options_t.args 。在 Unix 上被忽略。

  4. UV_PROCESS_DETACHED - 在新会话中启动子进程,该子进程将在父进程退出后继续运行。请参阅下面的示例。

分离进程

传递标志 UV_PROCESS_DETACHED 可用于启动守护进程或独立于父进程的子进程,以便父进程退出不会影响它。

int main() {
    loop = uv_default_loop();

    char *args[3];
    args[0] = "sleep";
    args[1] = "100";
    args[2] = NULL;

    options.exit_cb = NULL;
    options.file = "sleep";
    options.args = args;
    options.flags = UV_PROCESS_DETACHED;

    int r;
    if ((r = uv_spawn(loop, &child_req, &options))) {
        fprintf(stderr, "%s\n", uv_strerror(r));
        return 1;
    }
    fprintf(stderr, "Launched sleep with PID %d\n", child_req.pid);
    uv_unref((uv_handle_t*) &child_req);

    return uv_run(loop, UV_RUN_DEFAULT);

}

请记住,句柄仍在监视子进程,因此您的程序不会退出。如果您想要更加彻底分离,请使用 uv_unref()

向进程发送信号

libuv 包装了 Unix 上的标准 kill(2) 系统调用,并在 Windows 上实现了具有类似语义的系统调用,但有一个警告:所有 SIGTERMSIGINTSIGKILL 的签名是:

uv_err_t uv_kill(int pid, int signum);

对于使用 libuv 启动的进程,您可以使用 uv_process_kill 代替,它接受 uv_process_t 观察程序作为第一个参数,而不是 pid。在这种情况下,请记住在调用退出回调之后在观察器上调用 uv_close

信号

libuv 提供了 Unix 信号的包装器以及一些 Windows 支持。

使用 uv_signal_init() 初始化句柄并将其与循环关联。要侦听该处理程序上的特定信号,请将 uv_signal_start() 与处理程序函数一起使用。每个处理程序只能与一个信号号关联,随后对 uv_signal_start() 的调用会覆盖先前的关联。使用 uv_signal_stop() 停止观看。这是一个小例子,展示了各种可能性:

#include 
#include 
#include 
#include 

uv_loop_t* create_loop()
{
    uv_loop_t *loop = malloc(sizeof(uv_loop_t));
    if (loop) {
      uv_loop_init(loop);
    }
    return loop;
}

void signal_handler(uv_signal_t *handle, int signum)
{
    printf("Signal received: %d\n", signum);
    uv_signal_stop(handle);
}

// two signal handlers in one loop
void thread1_worker(void *userp)
{
    uv_loop_t *loop1 = create_loop();

    uv_signal_t sig1a, sig1b;
    uv_signal_init(loop1, &sig1a);
    uv_signal_start(&sig1a, signal_handler, SIGUSR1);

    uv_signal_init(loop1, &sig1b);
    uv_signal_start(&sig1b, signal_handler, SIGUSR1);

    uv_run(loop1, UV_RUN_DEFAULT);
}

// two signal handlers, each in its own loop
void thread2_worker(void *userp)
{
    uv_loop_t *loop2 = create_loop();
    uv_loop_t *loop3 = create_loop();

    uv_signal_t sig2;
    uv_signal_init(loop2, &sig2);
    uv_signal_start(&sig2, signal_handler, SIGUSR1);

    uv_signal_t sig3;
    uv_signal_init(loop3, &sig3);
    uv_signal_start(&sig3, signal_handler, SIGUSR1);

    while (uv_run(loop2, UV_RUN_NOWAIT) || uv_run(loop3, UV_RUN_NOWAIT)) {
    }
}

int main()
{
    printf("PID %d\n", getpid());

    uv_thread_t thread1, thread2;

    uv_thread_create(&thread1, thread1_worker, 0);
    uv_thread_create(&thread2, thread2_worker, 0);

    uv_thread_join(&thread1);
    uv_thread_join(&thread2);
    return 0;
}

uv_run(loop, UV_RUN_NOWAIT)uv_run(loop, UV_RUN_ONCE) 类似,它只处理一个事件。如果没有待处理事件,UV_RUN_ONCE 会阻塞,而 UV_RUN_NOWAIT 将立即返回。我们使用NOWAIT,这样其中一个循环就不会因为另一个循环没有待处理的活动而陷入饥饿状态。

SIGUSR1 发送到进程,您会发现处理程序被调用 4 次,每个 uv_signal_t 调用一次。处理程序只是停止每个句柄,以便程序退出。这种向所有处理程序的分派非常有用。使用多个事件循环的服务器可以确保在终止之前安全地保存所有数据,只需在每个循环中添加 SIGINT 的观察程序即可。

子进程I/O

一个正常的、新生成的进程有自己的一组文件描述符,其中 0、1 和 2 分别是 stdinstdoutstderr 。有时您可能想与孩子共享文件描述符。例如,也许您的应用程序启动了一个子命令,并且您希望将所有错误记录在日志文件中,但忽略 stdout 。为此,您希望子级的 stderr 与父级的 stderr 相同。在这种情况下,libuv支持继承文件描述符。在此示例中,我们调用测试程序,即:

#include 

int main()
{
    fprintf(stderr, "This is stderr\n");
    printf("This is stdout\n");
    return 0;
}

实际程序 proc-streams 在仅共享 stderr 的同时运行此程序。子进程的文件描述符是使用 uv_process_options_t 中的 stdio 字段设置的。首先将 stdio_count 字段设置为正在设置的文件描述符的数量。 uv_process_options_t.stdiouv_stdio_container_t 的数组,即:

typedef struct uv_stdio_container_s {
    uv_stdio_flags flags;

    union {
        uv_stream_t* stream;
        int fd;
    } data;
} uv_stdio_container_t;

其中标志可以有多个值。如果不使用,请使用 UV_IGNORE 。如果前三个 stdio 字段标记为 UV_IGNORE 它们将重定向到 /dev/null

由于我们想要传递现有的描述符,因此我们将使用 UV_INHERIT_FD 。然后我们将 fd 设置为 stderr

int main() {
    loop = uv_default_loop();

    /* ... */

    options.stdio_count = 3;
    uv_stdio_container_t child_stdio[3];
    child_stdio[0].flags = UV_IGNORE;
    child_stdio[1].flags = UV_IGNORE;
    child_stdio[2].flags = UV_INHERIT_FD;
    child_stdio[2].data.fd = 2;
    options.stdio = child_stdio;

    options.exit_cb = on_exit;
    options.file = args[0];
    options.args = args;

    int r;
    if ((r = uv_spawn(loop, &child_req, &options))) {
        fprintf(stderr, "%s\n", uv_strerror(r));
        return 1;
    }

    return uv_run(loop, UV_RUN_DEFAULT);
}

如果运行 proc-stream ,您将看到仅显示“This is stderr”行。尝试将 stdout 标记为继承并查看输出。

将这种重定向应用于流非常简单。通过将 flags 设置为 UV_INHERIT_STREAM 并将 data.stream 设置为父进程中的流,子进程可以将该流视为标准 I/O。这可以用来实现 CGI 之类的东西。

示例 CGI 脚本/可执行文件是:

#include 
#include 

int main() {
    int i;
    for (i = 0; i < 10; i++) {
        printf("tick\n");
        fflush(stdout);
        sleep(1);
    }
    printf("BOOM!\n");
    return 0;
}

CGI 服务器结合了网络的概念,以便向每个客户端发送十个时钟周期,然后关闭连接。

void on_new_connection(uv_stream_t *server, int status) {
    if (status == -1) {
        // error
        return;
    }
    uv_tcp_t *client = (uv_tcp_t*)malloc(sizeof(uv_tcp_t));
    uv_tcp_init(loop, client);
    if (uv_accept(server, (uv_stream_t*)client) == 0) {
        invoke_cgi_script(client);
    } else {
        uv_close((uv_handle_t*) client, NULL);
    }
}

这里我们只是接受 TCP 连接并将套接字(流)传递给 invoke_cgi_script

int main() {
    loop = uv_default_loop();

    uv_tcp_t server;
    uv_tcp_init(loop, &server);

    struct sockaddr_in bind_addr;
    uv_ip4_addr("0.0.0.0", 7000, &bind_addr);
    uv_tcp_bind(&server, (const struct sockaddr *)&bind_addr, 0);
    int r = uv_listen((uv_stream_t*) &server, 128, on_new_connection);
    if (r) {
        fprintf(stderr, "Listen error %s\n", uv_err_name(r));
        return 1;
    }
    return uv_run(loop, UV_RUN_DEFAULT);
}

CGI 脚本的 stdout 设置为套接字,以便我们的刻度脚本打印的任何内容都会发送到客户端。通过使用进程,我们可以将读/写缓冲卸载到操作系统,因此就便利性而言,这非常好。请注意,创建流程是一项成本高昂的任务。

父子进程IPC

父级和子级可以通过将 uv_stdio_container_t.flags 设置为 UV_CREATE_PIPEUV_READABLE_PIPEUV_WRITABLE_PIPE 。读/写标志是从子进程的角度来看的。在这种情况下, uv_stream_t* stream 字段必须设置为指向已初始化、未打开的 uv_pipe_t 实例。

新的 stdio 管道

uv_pipe_t 结构不仅仅表示 pipeline (或 | ),还支持任何类似流文件的对象。在 Windows 上,该描述的唯一对象是命名管道。在 Unix 上,这可以是任何 Unix 域套接字,或者派生自 mkfifo,或者它实际上可以是管道。当 uv_spawn 由于 UV_CREATE_PIPE 标志而初始化 uv_pipe_t 时,它会选择创建套接字对。

这样做的目的是允许多个 libuv 进程与 IPC 进行通信。这将在下面讨论。

任意进程IPC

由于域套接字可以具有众所周知的名称和文件系统中的位置,因此它们可以用于不相关进程之间的 IPC。开源桌面环境使用的 D-BUS 系统使用域套接字进行事件通知。当联系人上线或检测到新硬件时,各种应用程序可以做出反应。 MySQL 服务器还运行一个域套接字,客户端可以在该套接字上与其交互。

使用域套接字时,通常遵循客户端-服务器模式,套接字的创建者/所有者充当服务器。初始设置后,消息传递与 TCP 没有什么不同,因此我们将重新使用 echo 服务器示例。

void remove_sock(int sig) {
    uv_fs_t req;
    uv_fs_unlink(loop, &req, PIPENAME, NULL);
    exit(0);
}

int main() {
    loop = uv_default_loop();

    uv_pipe_t server;
    uv_pipe_init(loop, &server, 0);

    signal(SIGINT, remove_sock);

    int r;
    if ((r = uv_pipe_bind(&server, PIPENAME))) {
        fprintf(stderr, "Bind error %s\n", uv_err_name(r));
        return 1;
    }
    if ((r = uv_listen((uv_stream_t*) &server, 128, on_new_connection))) {
        fprintf(stderr, "Listen error %s\n", uv_err_name(r));
        return 2;
    }
    return uv_run(loop, UV_RUN_DEFAULT);
}

我们将套接字命名为 echo.sock ,这意味着它将在本地目录中创建。就流 API 而言,此套接字现在的行为与 TCP 套接字没有什么不同。您可以使用 socat 测试该服务器:

$ socat - /path/to/socket

想要连接到域套接字的客户端将使用:

void uv_pipe_connect(uv_connect_t *req, uv_pipe_t *handle, const char *name, uv_connect_cb cb);

其中 name 将是 echo.sock 或类似的。在 Unix 系统上, name 必须指向有效文件(例如 /tmp/echo.sock )。在 Windows 上, name 遵循 \\?\pipe\echo.sock 格式。

通过管道发送文件描述符

域套接字的一个很酷的事情是,可以通过域套接字发送文件描述符,从而在进程之间交换文件描述符。这允许进程将其 I/O 移交给其他进程。应用程序包括负载平衡服务器、工作进程和其他充分利用 CPU 的方法。 libuv 目前仅支持通过管道发送 TCP 套接字或其他管道。

为了进行演示,我们将查看一个回显服务器实现,该实现以循环方式将客户端交给工作进程。这个程序有点复杂,虽然书中只包含了一些片段,但建议阅读完整的代码以真正理解它。

工作进程非常简单,因为文件描述符是由主进程移交给它的。

uv_loop_t *loop;
uv_pipe_t queue;
int main() {
    loop = uv_default_loop();

    uv_pipe_init(loop, &queue, 1 /* ipc */);
    uv_pipe_open(&queue, 0);
    uv_read_start((uv_stream_t*)&queue, alloc_buffer, on_new_connection);
    return uv_run(loop, UV_RUN_DEFAULT);
}

queue 是连接到另一端主进程的管道,新的文件描述符沿着该管道发送。将 uv_pipe_init 的 ipc 参数设置为 1 非常重要,以指示此管道将用于进程间通信!由于主节点会将文件句柄写入工作节点的标准输入,因此我们使用 uv_pipe_open 将管道连接到 stdin

void on_new_connection(uv_stream_t *q, ssize_t nread, const uv_buf_t *buf) {
    if (nread < 0) {
        if (nread != UV_EOF)
            fprintf(stderr, "Read error %s\n", uv_err_name(nread));
        uv_close((uv_handle_t*) q, NULL);
        return;
    }

    uv_pipe_t *pipe = (uv_pipe_t*) q;
    if (!uv_pipe_pending_count(pipe)) {
        fprintf(stderr, "No pending count\n");
        return;
    }

    uv_handle_type pending = uv_pipe_pending_type(pipe);
    assert(pending == UV_TCP);

    uv_tcp_t *client = (uv_tcp_t*) malloc(sizeof(uv_tcp_t));
    uv_tcp_init(loop, client);
    if (uv_accept(q, (uv_stream_t*) client) == 0) {
        uv_os_fd_t fd;
        uv_fileno((const uv_handle_t*) client, &fd);
        fprintf(stderr, "Worker %d: Accepted fd %d\n", getpid(), fd);
        uv_read_start((uv_stream_t*) client, alloc_buffer, echo_read);
    }
    else {
        uv_close((uv_handle_t*) client, NULL);
    }
}

首先,我们调用 uv_pipe_pending_count() 以确保句柄可供读出。如果您的程序可以处理不同类型的句柄,则可以使用 uv_pipe_pending_type() 来确定类型。虽然这段代码中的 accept 看起来很奇怪,但它实际上是有道理的。 accept 传统上所做的是从另一个文件描述符(监听套接字)获取一个文件描述符(客户端)。这正是我们在这里所做的。从 queue 获取文件描述符 ( client )。从这一点开始,工作人员执行标准的回显服务器工作。

现在转向master,让我们看看如何启动worker以实现负载平衡。

struct child_worker {
    uv_process_t req;
    uv_process_options_t options;
    uv_pipe_t pipe;
} *workers;

child_worker 结构包装了进程以及主进程和单个进程之间的管道。

void setup_workers() {
    round_robin_counter = 0;

    // ...

    // launch same number of workers as number of CPUs
    uv_cpu_info_t *info;
    int cpu_count;
    uv_cpu_info(&info, &cpu_count);
    uv_free_cpu_info(info, cpu_count);

    child_worker_count = cpu_count;

    workers = calloc(cpu_count, sizeof(struct child_worker));
    while (cpu_count--) {
        struct child_worker *worker = &workers[cpu_count];
        uv_pipe_init(loop, &worker->pipe, 1);

        uv_stdio_container_t child_stdio[3];
        child_stdio[0].flags = UV_CREATE_PIPE | UV_READABLE_PIPE;
        child_stdio[0].data.stream = (uv_stream_t*) &worker->pipe;
        child_stdio[1].flags = UV_IGNORE;
        child_stdio[2].flags = UV_INHERIT_FD;
        child_stdio[2].data.fd = 2;

        worker->options.stdio = child_stdio;
        worker->options.stdio_count = 3;

        worker->options.exit_cb = close_process_handle;
        worker->options.file = args[0];
        worker->options.args = args;

        uv_spawn(loop, &worker->req, &worker->options); 
        fprintf(stderr, "Started worker %d\n", worker->req.pid);
    }
}

在设置worker时,我们使用漂亮的libuv函数 uv_cpu_info 来获取CPU的数量,这样我们就可以启动相同数量的worker。同样重要的是初始化充当 IPC 通道的管道,第三个参数为 1。然后我们指示子进程的 stdin 将是一个可读管道(从子进程的角度来看) )。到这里为止一切都很简单。工作线程启动并等待文件描述符写入其标准输入。

正是在 on_new_connection 中(TCP 基础设施在 main() 中初始化),我们接受客户端套接字并将其传递给循环中的下一个工作程序。

void on_new_connection(uv_stream_t *server, int status) {
    if (status == -1) {
        // error!
        return;
    }

    uv_tcp_t *client = (uv_tcp_t*) malloc(sizeof(uv_tcp_t));
    uv_tcp_init(loop, client);
    if (uv_accept(server, (uv_stream_t*) client) == 0) {
        uv_write_t *write_req = (uv_write_t*) malloc(sizeof(uv_write_t));
        dummy_buf = uv_buf_init("a", 1);
        struct child_worker *worker = &workers[round_robin_counter];
        uv_write2(write_req, (uv_stream_t*) &worker->pipe, &dummy_buf, 1, (uv_stream_t*) client, NULL);
        round_robin_counter = (round_robin_counter + 1) % child_worker_count;
    }
    else {
        uv_close((uv_handle_t*) client, NULL);
    }
}

uv_write2 调用处理所有抽象,只需将句柄 ( client ) 作为正确参数传递即可。这样我们的多进程回显服务器就可以运行了。即使在发送句柄时 uv_write2() 也需要非空缓冲区。

作者: 岬淢箫声
日期: 2023年11月2日
版本: 1.0
链接: http://caowei.blog.csdn.net

你可能感兴趣的:(Socket,linux,服务器,单片机,嵌入式,libuv)