2018年3月

这是并发网络服务器系列文章的第四部分。在这一部分中,我们将使用 libuv 再次重写我们的服务器,并且也会讨论关于使用一个线程池在回调中去处理耗时任务。最终,我们去看一下底层的 libuv,花一点时间去学习如何用异步 API 对文件系统阻塞操作进行封装。

本系列的所有文章:

使用 libuv 抽象出事件驱动循环

第三节 中,我们看到了基于 selectepoll 的服务器的相似之处,并且,我说过,在它们之间抽象出细微的差别是件很有吸引力的事。许多库已经做到了这些,所以在这一部分中我将去选一个并使用它。我选的这个库是 libuv,它最初设计用于 Node.js 底层的可移植平台层,并且,后来发现在其它的项目中也有使用。libuv 是用 C 写的,因此,它具有很高的可移植性,非常适用嵌入到像 JavaScript 和 Python 这样的高级语言中。

虽然 libuv 为了抽象出底层平台细节已经变成了一个相当大的框架,但它仍然是以 事件循环 思想为中心的。在我们第三部分的事件驱动服务器中,事件循环是显式定义在 main 函数中的;当使用 libuv 时,该循环通常隐藏在库自身中,而用户代码仅需要注册事件句柄(作为一个回调函数)和运行这个循环。此外,libuv 会在给定的平台上使用更快的事件循环实现,对于 Linux 它是 epoll,等等。

libuv loop

libuv 支持多路事件循环,因此事件循环在库中是非常重要的;它有一个句柄 —— uv_loop_t,以及创建/杀死/启动/停止循环的函数。也就是说,在这篇文章中,我将仅需要使用 “默认的” 循环,libuv 可通过 uv_default_loop() 提供它;多路循环大多用于多线程事件驱动的服务器,这是一个更高级别的话题,我将留在这一系列文章的以后部分。

使用 libuv 的并发服务器

为了对 libuv 有一个更深的印象,让我们跳转到我们的可靠协议的服务器,它通过我们的这个系列已经有了一个强大的重新实现。这个服务器的结构与第三部分中的基于 selectepoll 的服务器有一些相似之处,因为,它也依赖回调。完整的 示例代码在这里;我们开始设置这个服务器的套接字绑定到一个本地端口:

int portnum = 9090;
if (argc >= 2) {
  portnum = atoi(argv[1]);
}
printf("Serving on port %d\n", portnum);

int rc;
uv_tcp_t server_stream;
if ((rc = uv_tcp_init(uv_default_loop(), &server_stream)) < 0) {
  die("uv_tcp_init failed: %s", uv_strerror(rc));
}

struct sockaddr_in server_address;
if ((rc = uv_ip4_addr("0.0.0.0", portnum, &server_address)) < 0) {
  die("uv_ip4_addr failed: %s", uv_strerror(rc));
}

if ((rc = uv_tcp_bind(&server_stream, (const struct sockaddr*)&server_address, 0)) < 0) {
  die("uv_tcp_bind failed: %s", uv_strerror(rc));
}

除了它被封装进 libuv API 中之外,你看到的是一个相当标准的套接字。在它的返回中,我们取得了一个可工作于任何 libuv 支持的平台上的可移植接口。

这些代码也展示了很认真负责的错误处理;多数的 libuv 函数返回一个整数状态,返回一个负数意味着出现了一个错误。在我们的服务器中,我们把这些错误看做致命问题进行处理,但也可以设想一个更优雅的错误恢复。

现在,那个套接字已经绑定,是时候去监听它了。这里我们运行首个回调注册:

// Listen on the socket for new peers to connect. When a new peer connects,
// the on_peer_connected callback will be invoked.
if ((rc = uv_listen((uv_stream_t*)&server_stream, N_BACKLOG, on_peer_connected)) < 0) {
  die("uv_listen failed: %s", uv_strerror(rc));
}

uv_listen 注册一个事件回调,当新的对端连接到这个套接字时将会调用事件循环。我们的回调在这里被称为 on_peer_connected,我们一会儿将去查看它。

最终,main 运行这个 libuv 循环,直到它被停止(uv_run 仅在循环被停止或者发生错误时返回)。

// Run the libuv event loop.
uv_run(uv_default_loop(), UV_RUN_DEFAULT);

// If uv_run returned, close the default loop before exiting.
return uv_loop_close(uv_default_loop());

注意,在运行事件循环之前,只有一个回调是通过 main 注册的;我们稍后将看到怎么去添加更多的回调。在事件循环的整个运行过程中,添加和删除回调并不是一个问题 —— 事实上,大多数服务器就是这么写的。

这是一个 on_peer_connected,它处理到服务器的新的客户端连接:

void on_peer_connected(uv_stream_t* server_stream, int status) {
  if (status < 0) {
    fprintf(stderr, "Peer connection error: %s\n", uv_strerror(status));
    return;
  }

  // client will represent this peer; it's allocated on the heap and only
  // released when the client disconnects. The client holds a pointer to
  // peer_state_t in its data field; this peer state tracks the protocol state
  // with this client throughout interaction.
  uv_tcp_t* client = (uv_tcp_t*)xmalloc(sizeof(*client));
  int rc;
  if ((rc = uv_tcp_init(uv_default_loop(), client)) < 0) {
    die("uv_tcp_init failed: %s", uv_strerror(rc));
  }
  client->data = NULL;

  if (uv_accept(server_stream, (uv_stream_t*)client) == 0) {
    struct sockaddr_storage peername;
    int namelen = sizeof(peername);
    if ((rc = uv_tcp_getpeername(client, (struct sockaddr*)&peername,
                                 &namelen)) < 0) {
      die("uv_tcp_getpeername failed: %s", uv_strerror(rc));
    }
    report_peer_connected((const struct sockaddr_in*)&peername, namelen);

    // Initialize the peer state for a new client: we start by sending the peer
    // the initial '*' ack.
    peer_state_t* peerstate = (peer_state_t*)xmalloc(sizeof(*peerstate));
    peerstate->state = INITIAL_ACK;
    peerstate->sendbuf[0] = '*';
    peerstate->sendbuf_end = 1;
    peerstate->client = client;
    client->data = peerstate;

    // Enqueue the write request to send the ack; when it's done,
    // on_wrote_init_ack will be called. The peer state is passed to the write
    // request via the data pointer; the write request does not own this peer
    // state - it's owned by the client handle.
    uv_buf_t writebuf = uv_buf_init(peerstate->sendbuf, peerstate->sendbuf_end);
    uv_write_t* req = (uv_write_t*)xmalloc(sizeof(*req));
    req->data = peerstate;
    if ((rc = uv_write(req, (uv_stream_t*)client, &writebuf, 1,
                       on_wrote_init_ack)) < 0) {
      die("uv_write failed: %s", uv_strerror(rc));
    }
  } else {
    uv_close((uv_handle_t*)client, on_client_closed);
  }
}

这些代码都有很好的注释,但是,这里有一些重要的 libuv 语法我想去强调一下:

  • 传入自定义数据到回调中:因为 C 语言还没有闭包,这可能是个挑战,libuv 在它的所有的处理类型中有一个 void* data 字段;这些字段可以被用于传递用户数据。例如,注意 client->data 是如何指向到一个 peer_state_t 结构上,以便于 uv_writeuv_read_start 注册的回调可以知道它们正在处理的是哪个客户端的数据。
  • 内存管理:在带有垃圾回收的语言中进行事件驱动编程是非常容易的,因为,回调通常运行在一个与它们注册的地方完全不同的栈帧中,使得基于栈的内存管理很困难。它总是需要传递堆分配的数据到 libuv 回调中(当所有回调运行时,除了 main,其它的都运行在栈上),并且,为了避免泄漏,许多情况下都要求这些数据去安全释放(free())。这些都是些需要实践的内容 注1

这个服务器上对端的状态如下:

typedef struct {
  ProcessingState state;
  char sendbuf[SENDBUF_SIZE];
  int sendbuf_end;
  uv_tcp_t* client;
} peer_state_t;

它与第三部分中的状态非常类似;我们不再需要 sendptr,因为,在调用 “done writing” 回调之前,uv_write 将确保发送它提供的整个缓冲。我们也为其它的回调使用保持了一个到客户端的指针。这里是 on_wrote_init_ack

void on_wrote_init_ack(uv_write_t* req, int status) {
  if (status) {
    die("Write error: %s\n", uv_strerror(status));
  }
  peer_state_t* peerstate = (peer_state_t*)req->data;
  // Flip the peer state to WAIT_FOR_MSG, and start listening for incoming data
  // from this peer.
  peerstate->state = WAIT_FOR_MSG;
  peerstate->sendbuf_end = 0;

  int rc;
  if ((rc = uv_read_start((uv_stream_t*)peerstate->client, on_alloc_buffer,
                          on_peer_read)) < 0) {
    die("uv_read_start failed: %s", uv_strerror(rc));
  }

  // Note: the write request doesn't own the peer state, hence we only free the
  // request itself, not the state.
  free(req);
}

然后,我们确信知道了这个初始的 '*' 已经被发送到对端,我们通过调用 uv_read_start 去监听从这个对端来的入站数据,它注册一个将被事件循环调用的回调(on_peer_read),不论什么时候,事件循环都在套接字上接收来自客户端的调用:

void on_peer_read(uv_stream_t* client, ssize_t nread, const uv_buf_t* buf) {
  if (nread < 0) {
    if (nread != uv_eof) {
      fprintf(stderr, "read error: %s\n", uv_strerror(nread));
    }
    uv_close((uv_handle_t*)client, on_client_closed);
  } else if (nread == 0) {
    // from the documentation of uv_read_cb: nread might be 0, which does not
    // indicate an error or eof. this is equivalent to eagain or ewouldblock
    // under read(2).
  } else {
    // nread > 0
    assert(buf->len >= nread);

    peer_state_t* peerstate = (peer_state_t*)client->data;
    if (peerstate->state == initial_ack) {
      // if the initial ack hasn't been sent for some reason, ignore whatever
      // the client sends in.
      free(buf->base);
      return;
    }

    // run the protocol state machine.
    for (int i = 0; i < nread; ++i) {
      switch (peerstate->state) {
      case initial_ack:
        assert(0 && "can't reach here");
        break;
      case wait_for_msg:
        if (buf->base[i] == '^') {
          peerstate->state = in_msg;
        }
        break;
      case in_msg:
        if (buf->base[i] == '$') {
          peerstate->state = wait_for_msg;
        } else {
          assert(peerstate->sendbuf_end < sendbuf_size);
          peerstate->sendbuf[peerstate->sendbuf_end++] = buf->base[i] + 1;
        }
        break;
      }
    }

    if (peerstate->sendbuf_end > 0) {
      // we have data to send. the write buffer will point to the buffer stored
      // in the peer state for this client.
      uv_buf_t writebuf =
          uv_buf_init(peerstate->sendbuf, peerstate->sendbuf_end);
      uv_write_t* writereq = (uv_write_t*)xmalloc(sizeof(*writereq));
      writereq->data = peerstate;
      int rc;
      if ((rc = uv_write(writereq, (uv_stream_t*)client, &writebuf, 1,
                         on_wrote_buf)) < 0) {
        die("uv_write failed: %s", uv_strerror(rc));
      }
    }
  }
  free(buf->base);
}

这个服务器的运行时行为非常类似于第三部分的事件驱动服务器:所有的客户端都在一个单个的线程中并发处理。并且类似的,一些特定的行为必须在服务器代码中维护:服务器的逻辑实现为一个集成的回调,并且长周期运行是禁止的,因为它会阻塞事件循环。这一点也很类似。让我们进一步探索这个问题。

在事件驱动循环中的长周期运行的操作

单线程的事件驱动代码使它先天就容易受到一些常见问题的影响:长周期运行的代码会阻塞整个循环。参见如下的程序:

void on_timer(uv_timer_t* timer) {
  uint64_t timestamp = uv_hrtime();
  printf("on_timer [%" PRIu64 " ms]\n", (timestamp / 1000000) % 100000);

  // "Work"
  if (random() % 5 == 0) {
    printf("Sleeping...\n");
    sleep(3);
  }
}

int main(int argc, const char** argv) {
  uv_timer_t timer;
  uv_timer_init(uv_default_loop(), &timer);
  uv_timer_start(&timer, on_timer, 0, 1000);
  return uv_run(uv_default_loop(), UV_RUN_DEFAULT);
}

它用一个单个注册的回调运行一个 libuv 事件循环:on_timer,它被每秒钟循环调用一次。回调报告一个时间戳,并且,偶尔通过睡眠 3 秒去模拟一个长周期运行。这是运行示例:

$ ./uv-timer-sleep-demo
on_timer [4840 ms]
on_timer [5842 ms]
on_timer [6843 ms]
on_timer [7844 ms]
Sleeping...
on_timer [11845 ms]
on_timer [12846 ms]
Sleeping...
on_timer [16847 ms]
on_timer [17849 ms]
on_timer [18850 ms]
...

on_timer 忠实地每秒执行一次,直到随机出现的睡眠为止。在那个时间点,on_timer 不再被调用,直到睡眠时间结束;事实上,没有其它的回调 会在这个时间帧中被调用。这个睡眠调用阻塞了当前线程,它正是被调用的线程,并且也是事件循环使用的线程。当这个线程被阻塞后,事件循环也被阻塞。

这个示例演示了在事件驱动的调用中为什么回调不能被阻塞是多少的重要。并且,同样适用于 Node.js 服务器、客户端侧的 Javascript、大多数的 GUI 编程框架、以及许多其它的异步编程模型。

但是,有时候运行耗时的任务是不可避免的。并不是所有任务都有一个异步 API;例如,我们可能使用一些仅有同步 API 的库去处理,或者,正在执行一个可能的长周期计算。我们如何用事件驱动编程去结合这些代码?线程可以帮到你!

“转换” 阻塞调用为异步调用的线程

一个线程池可以用于转换阻塞调用为异步调用,通过与事件循环并行运行,并且当任务完成时去由它去公布事件。以阻塞函数 do_work() 为例,这里介绍了它是怎么运行的:

  1. 不在一个回调中直接调用 do_work() ,而是将它打包进一个 “任务”,让线程池去运行这个任务。当任务完成时,我们也为循环去调用它注册一个回调;我们称它为 on_work_done()
  2. 在这个时间点,我们的回调就可以返回了,而事件循环保持运行;在同一时间点,线程池中的有一个线程运行这个任务。
  3. 一旦任务运行完成,通知主线程(指正在运行事件循环的线程),并且事件循环调用 on_work_done()

让我们看一下,使用 libuv 的工作调度 API,是怎么去解决我们前面的计时器/睡眠示例中展示的问题的:

void on_after_work(uv_work_t* req, int status) {
  free(req);
}

void on_work(uv_work_t* req) {
  // "Work"
  if (random() % 5 == 0) {
    printf("Sleeping...\n");
    sleep(3);
  }
}

void on_timer(uv_timer_t* timer) {
  uint64_t timestamp = uv_hrtime();
  printf("on_timer [%" PRIu64 " ms]\n", (timestamp / 1000000) % 100000);

  uv_work_t* work_req = (uv_work_t*)malloc(sizeof(*work_req));
  uv_queue_work(uv_default_loop(), work_req, on_work, on_after_work);
}

int main(int argc, const char** argv) {
  uv_timer_t timer;
  uv_timer_init(uv_default_loop(), &timer);
  uv_timer_start(&timer, on_timer, 0, 1000);
  return uv_run(uv_default_loop(), UV_RUN_DEFAULT);
}

通过一个 work_req 注2 类型的句柄,我们进入一个任务队列,代替在 on_timer 上直接调用 sleep,这个函数在任务中(on_work)运行,并且,一旦任务完成(on_after_work),这个函数被调用一次。on_work 是指 “work”(阻塞中的/耗时的操作)进行的地方。注意在这两个回调传递到 uv_queue_work 时的一个关键区别:on_work 运行在线程池中,而 on_after_work 运行在事件循环中的主线程上 —— 就好像是其它的回调一样。

让我们看一下这种方式的运行:

$ ./uv-timer-work-demo
on_timer [89571 ms]
on_timer [90572 ms]
on_timer [91573 ms]
on_timer [92575 ms]
Sleeping...
on_timer [93576 ms]
on_timer [94577 ms]
Sleeping...
on_timer [95577 ms]
on_timer [96578 ms]
on_timer [97578 ms]
...

即便在 sleep 函数被调用时,定时器也每秒钟滴答一下,睡眠现在运行在一个单独的线程中,并且不会阻塞事件循环。

一个用于练习的素数测试服务器

因为通过睡眠去模拟工作并不是件让人兴奋的事,我有一个事先准备好的更综合的一个示例 —— 一个基于套接字接受来自客户端的数字的服务器,检查这个数字是否是素数,然后去返回一个 “prime" 或者 “composite”。完整的 服务器代码在这里 —— 我不在这里粘贴了,因为它太长了,更希望读者在一些自己的练习中去体会它。

这个服务器使用了一个原生的素数测试算法,因此,对于大的素数可能花很长时间才返回一个回答。在我的机器中,对于 2305843009213693951,它花了 ~5 秒钟去计算,但是,你的方法可能不同。

练习 1:服务器有一个设置(通过一个名为 MODE 的环境变量)要么在套接字回调(意味着在主线程上)中运行素数测试,要么在 libuv 工作队列中。当多个客户端同时连接时,使用这个设置来观察服务器的行为。当它计算一个大的任务时,在阻塞模式中,服务器将不回复其它客户端,而在非阻塞模式中,它会回复。

练习 2:libuv 有一个缺省大小的线程池,并且线程池的大小可以通过环境变量配置。你可以通过使用多个客户端去实验找出它的缺省值是多少?找到线程池缺省值后,使用不同的设置去看一下,在重负载下怎么去影响服务器的响应能力。

在非阻塞文件系统中使用工作队列

对于只是呆板的演示和 CPU 密集型的计算来说,将可能的阻塞操作委托给一个线程池并不是明智的;libuv 在它的文件系统 API 中本身就大量使用了这种能力。通过这种方式,libuv 使用一个异步 API,以一个轻便的方式显示出它强大的文件系统的处理能力。

让我们使用 uv_fs_read(),例如,这个函数从一个文件中(表示为一个 uv_fs_t 句柄)读取一个文件到一个缓冲中 注3,并且当读取完成后调用一个回调。换句话说, uv_fs_read() 总是立即返回,即使是文件在一个类似 NFS 的系统上,而数据到达缓冲区可能需要一些时间。换句话说,这个 API 与这种方式中其它的 libuv API 是异步的。这是怎么工作的呢?

在这一点上,我们看一下 libuv 的底层;内部实际上非常简单,并且它是一个很好的练习。作为一个可移植的库,libuv 对于 Windows 和 Unix 系统在它的许多函数上有不同的实现。我们去看一下在 libuv 源树中的 src/unix/fs.c

这是 uv_fs_read 的代码:

int uv_fs_read(uv_loop_t* loop, uv_fs_t* req,
               uv_file file,
               const uv_buf_t bufs[],
               unsigned int nbufs,
               int64_t off,
               uv_fs_cb cb) {
  if (bufs == NULL || nbufs == 0)
    return -EINVAL;

  INIT(READ);
  req->file = file;

  req->nbufs = nbufs;
  req->bufs = req->bufsml;
  if (nbufs > ARRAY_SIZE(req->bufsml))
    req->bufs = uv__malloc(nbufs * sizeof(*bufs));

  if (req->bufs == NULL) {
    if (cb != NULL)
      uv__req_unregister(loop, req);
    return -ENOMEM;
  }

  memcpy(req->bufs, bufs, nbufs * sizeof(*bufs));

  req->off = off;
  POST;
}

第一次看可能觉得很困难,因为它延缓真实的工作到 INITPOST 宏中,以及为 POST 设置了一些本地变量。这样做可以避免了文件中的许多重复代码。

这是 INIT 宏:

#define INIT(subtype)                                                         \
  do {                                                                        \
    req->type = UV_FS;                                                        \
    if (cb != NULL)                                                           \
      uv__req_init(loop, req, UV_FS);                                         \
    req->fs_type = UV_FS_ ## subtype;                                         \
    req->result = 0;                                                          \
    req->ptr = NULL;                                                          \
    req->loop = loop;                                                         \
    req->path = NULL;                                                         \
    req->new_path = NULL;                                                     \
    req->cb = cb;                                                             \
  }                                                                           \
  while (0)

它设置了请求,并且更重要的是,设置 req->fs_type 域为真实的 FS 请求类型。因为 uv_fs_read 调用 INIT(READ),它意味着 req->fs_type 被分配一个常数 UV_FS_READ

这是 POST 宏:

#define POST                                                                  \
  do {                                                                        \
    if (cb != NULL) {                                                         \
      uv__work_submit(loop, &req->work_req, uv__fs_work, uv__fs_done);        \
      return 0;                                                               \
    }                                                                         \
    else {                                                                    \
      uv__fs_work(&req->work_req);                                            \
      return req->result;                                                     \
    }                                                                         \
  }                                                                           \
  while (0)

它做什么取决于回调是否为 NULL。在 libuv 文件系统 API 中,一个 NULL 回调意味着我们真实地希望去执行一个 同步 操作。在这种情况下,POST 直接调用 uv__fs_work(我们需要了解一下这个函数的功能),而对于一个非 NULL 回调,它把 uv__fs_work 作为一个工作项提交到工作队列(指的是线程池),然后,注册 uv__fs_done 作为回调;该函数执行一些登记并调用用户提供的回调。

如果我们去看 uv__fs_work 的代码,我们将看到它使用很多宏按照需求将工作分发到实际的文件系统调用。在我们的案例中,对于 UV_FS_READ 这个调用将被 uv__fs_read 生成,它(最终)使用普通的 POSIX API 去读取。这个函数可以在一个 阻塞 方式中很安全地实现。因为,它通过异步 API 调用时被置于一个线程池中。

在 Node.js 中,fs.readFile 函数是映射到 uv_fs_read 上。因此,可以在一个非阻塞模式中读取文件,甚至是当底层文件系统 API 是阻塞方式时。


  • 注1: 为确保服务器不泄露内存,我在一个启用泄露检查的 Valgrind 中运行它。因为服务器经常是被设计为永久运行,这是一个挑战;为克服这个问题,我在服务器上添加了一个 “kill 开关” —— 一个从客户端接收的特定序列,以使它可以停止事件循环并退出。这个代码在 theon_wrote_buf 句柄中。
  • 注2: 在这里我们不过多地使用 work_req;讨论的素数测试服务器接下来将展示怎么被用于去传递上下文信息到回调中。
  • 注3: uv_fs_read() 提供了一个类似于 preadv Linux 系统调用的通用 API:它使用多缓冲区用于排序,并且支持一个到文件中的偏移。基于我们讨论的目的可以忽略这些特性。

via: https://eli.thegreenplace.net/2017/concurrent-servers-part-4-libuv/

作者:Eli Bendersky 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

如果你的同事问你一个不太清晰的问题,你会怎么回答?我认为提问题是一种技巧(可以看 如何提出有意义的问题) ,同时,合理地回答问题也是一种技巧,它们都是非常实用的。

一开始 —— 有时向你提问的人不尊重你的时间,这很糟糕。理想情况下,我们假设问你问题的人是一个理性的人并且正在尽力解决问题,而你想帮助他们。和我一起工作的人是这样,我所生活的世界也是这样。当然,现实生活并不是这样。

下面是有助于回答问题的一些方法!

如果他们的提问不清楚,帮他们澄清

通常初学者不会提出很清晰的问题,或者问一些对回答问题没有必要信息的问题。你可以尝试以下方法 澄清问题:

  • 重述为一个更明确的问题来回复他们(“你是想问 X 吗?”)
  • 向他们了解更具体的他们并没有提供的信息 (“你使用 IPv6 ?”)
  • 问是什么导致了他们的问题。例如,有时有些人会进入我的团队频道,询问我们的 服务发现 service discovery 如何工作的。这通常是因为他们试图设置/重新配置服务。在这种情况下,如果问“你正在使用哪种服务?可以给我看看你正在处理的‘拉取请求’吗?”是有帮助的。

这些方法很多来自如何提出有意义的问题中的要点。(尽管我永远不会对某人说“噢,你得先看完《如何提出有意义的问题》这篇文章后再来向我提问)

弄清楚他们已经知道了什么

在回答问题之前,知道对方已经知道什么是非常有用的!

Harold Treen 给了我一个很好的例子:

前几天,有人请我解释 “Redux-Sagas”。与其深入解释,不如说 “它们就像监听 action 的工人线程,并可以让你更新 Redux store。

我开始搞清楚他们对 Redux、action、store 以及其他基本概念了解多少。将这些概念都联系在一起再来解释会容易得多。

弄清楚问你问题的人已经知道什么是非常重要的。因为有时他们可能会对基础概念感到疑惑(“Redux 是什么?”),或者他们可能是专家,但是恰巧遇到了微妙的 极端情况 corner case 。如果答案建立在他们不知道的概念上会令他们困惑,但如果重述他们已经知道的的又会是乏味的。

这里有一个很实用的技巧来了解他们已经知道什么 - 比如可以尝试用“你对 X 了解多少?”而不是问“你知道 X 吗?”。

给他们一个文档

“RTFM” ( “去读那些他妈的手册” Read The Fucking Manual )是一个典型的无用的回答,但事实上如果向他们指明一个特定的文档会是非常有用的!当我提问题的时候,我当然很乐意翻看那些能实际解决我的问题的文档,因为它也可能解决其他我想问的问题。

我认为明确你所给的文档的确能够解决问题是非常重要的,或者至少经过查阅后确认它对解决问题有帮助。否则,你可能将以下面这种情形结束对话(非常常见):

  • Ali:我应该如何处理 X ?
  • Jada:<文档链接>
  • Ali: 这个没有实际解释如何处理 X ,它仅仅解释了如何处理 Y !

如果我所给的文档特别长,我会指明文档中那个我将会谈及的特定部分。bash 手册 有 44000 个字(真的!),所以如果只说“它在 bash 手册中有说明”是没有帮助的 :)

告诉他们一个有用的搜索

在工作中,我经常发现我可以利用我所知道的关键字进行搜索来找到能够解决我的问题的答案。对于初学者来说,这些关键字往往不是那么明显。所以说“这是我用来寻找这个答案的搜索”可能有用些。再次说明,回答时请经检查后以确保搜索能够得到他们所需要的答案 :)

写新文档

人们经常一次又一次地问我的团队同样的问题。很显然这并不是他们的错(他们怎么能够知道在他们之前已经有 10 个人问了这个问题,且知道答案是什么呢?)因此,我们会尝试写新文档,而不是直接回答回答问题。

  1. 马上写新文档
  2. 给他们我们刚刚写好的新文档
  3. 公示

写文档有时往往比回答问题需要花很多时间,但这是值得的。写文档尤其重要,如果:

a. 这个问题被问了一遍又一遍 b. 随着时间的推移,这个答案不会变化太大(如果这个答案每一个星期或者一个月就会变化,文档就会过时并且令人受挫)

解释你做了什么

对于一个话题,作为初学者来说,这样的交流会真让人沮丧:

  • 新人:“嗨!你如何处理 X ?”
  • 有经验的人:“我已经处理过了,而且它已经完美解决了”
  • 新人:”...... 但是你做了什么?!“

如果问你问题的人想知道事情是如何进行的,这样是有帮助的:

  • 让他们去完成任务而不是自己做
  • 告诉他们你是如何得到你给他们的答案的。

这可能比你自己做的时间还要长,但对于被问的人来说这是一个学习机会,因为那样做使得他们将来能够更好地解决问题。

这样,你可以进行更好的交流,像这:

  • 新人:“这个网站出现了错误,发生了什么?”
  • 有经验的人:(2分钟后)“oh 这是因为发生了数据库故障转移”
  • 新人: “你是怎么知道的??!?!?”
  • 有经验的人:“以下是我所做的!”:

    1. 通常这些错误是因为服务器 Y 被关闭了。我查看了一下 $PLACE 但它表明服务器 Y 开着。所以,并不是这个原因导致的。
    2. 然后我查看 X 的仪表盘 ,仪表盘的这个部分显示这里发生了数据库故障转移。
    3. 然后我在日志中找到了相应服务器,并且它显示连接数据库错误,看起来错误就是这里。

如果你正在解释你是如何调试一个问题,解释你是如何发现问题,以及如何找出问题的。尽管看起来你好像已经得到正确答案,但感觉更好的是能够帮助他们提高学习和诊断能力,并了解可用的资源。

解决根本问题

这一点有点棘手。有时候人们认为他们依旧找到了解决问题的正确途径,且他们只要再多一点信息就可以解决问题。但他们可能并不是走在正确的道路上!比如:

  • George:“我在处理 X 的时候遇到了错误,我该如何修复它?”
  • Jasminda:“你是正在尝试解决 Y 吗?如果是这样,你不应该处理 X ,反而你应该处理 Z 。”
  • George:“噢,你是对的!!!谢谢你!我回反过来处理 Z 的。”

Jasminda 一点都没有回答 George 的问题!反而,她猜测 George 并不想处理 X ,并且她是猜对了。这是非常有用的!

如果你这样做可能会产生高高在上的感觉:

  • George:“我在处理 X 的时候遇到了错误,我该如何修复它?”
  • Jasminda:“不要这样做,如果你想处理 Y ,你应该反过来完成 Z 。”
  • George:“好吧,我并不是想处理 Y 。实际上我想处理 X 因为某些原因(REASONS)。所以我该如何处理 X 。”

所以不要高高在上,且要记住有时有些提问者可能已经偏离根本问题很远了。同时回答提问者提出的问题以及他们本该提出的问题都是合理的:“嗯,如果你想处理 X ,那么你可能需要这么做,但如果你想用这个解决 Y 问题,可能通过处理其他事情你可以更好地解决这个问题,这就是为什么可以做得更好的原因。”

询问“那个回答可以解决您的问题吗?”

我总是喜欢在我回答了问题之后核实是否真的已经解决了问题:“这个回答解决了您的问题吗?您还有其他问题吗?”在问完这个之后最好等待一会,因为人们通常需要一两分钟来知道他们是否已经找到了答案。

我发现尤其是问“这个回答解决了您的问题吗”这个额外的步骤在写完文档后是非常有用的。通常,在写关于我熟悉的东西的文档时,我会忽略掉重要的东西而不会意识到它。

结对编程和面对面交谈

我是远程工作的,所以我的很多对话都是基于文本的。我认为这是沟通的默认方式。

今天,我们生活在一个方便进行小视频会议和屏幕共享的世界!在工作时候,在任何时间我都可以点击一个按钮并快速加入与他人的视频对话或者屏幕共享的对话中!

例如,最近有人问如何自动调节他们的服务容量规划。我告诉他们我们有几样东西需要清理,但我还不太确定他们要清理的是什么。然后我们进行了一个简短的视频会话并在 5 分钟后,我们解决了他们问题。

我认为,特别是如果有人真的被困在该如何开始一项任务时,开启视频进行结对编程几分钟真的比电子邮件或者一些即时通信更有效。

不要表现得过于惊讶

这是源自 Recurse Center 的一则法则:不要故作惊讶。这里有一个常见的情景:

  • 某甲:“什么是 Linux 内核”
  • 某乙:“你竟然不知道什么是 Linux 内核?!!!!?!!!????”

某乙的表现(无论他们是否真的如此惊讶)是没有帮助的。这大部分只会让某甲不好受,因为他们确实不知道什么是 Linux 内核。

我一直在假装不惊讶,即使我事实上确实有点惊讶那个人不知道这种东西。

回答问题真的很棒

显然并不是所有方法都是合适的,但希望你能够发现这里有些是有帮助的!我发现花时间去回答问题并教导人们是其实是很有收获的。

特别感谢 Josh Triplett 的一些建议并做了很多有益的补充,以及感谢 Harold Treen、Vaibhav Sagar、Peter Bhat Hatkins、Wesley Aptekar Cassels 和 Paul Gowder 的阅读或评论。


via: https://jvns.ca/blog/answer-questions-well/

作者:Julia Evans 译者:HardworkFish 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

在学习了进程的 虚拟地址布局 之后,让我们回到内核,来学习它管理用户内存的机制。这里再次使用 Gonzo:

Linux kernel mm_struct

Linux 进程在内核中是作为进程描述符 task\_struct (LCTT 译注:它是在 Linux 中描述进程完整信息的一种数据结构)的实例来实现的。在 task\_struct 中的 mm 域指向到内存描述符mm\_struct 是一个程序在内存中的执行摘要。如上图所示,它保存了起始和结束内存段,进程使用的物理内存页面的 数量(RSS 常驻内存大小 Resident Set Size )、虚拟地址空间使用的 总数量、以及其它片断。 在内存描述符中,我们可以获悉它有两种管理内存的方式:虚拟内存区域集和页面表。Gonzo 的内存区域如下所示:

Kernel memory descriptor and memory areas

每个虚拟内存区域(VMA)是一个连续的虚拟地址范围;这些区域绝对不会重叠。一个 vm\_area\_struct 的实例完整地描述了一个内存区域,包括它的起始和结束地址,flags 决定了访问权限和行为,并且 vm\_file 域指定了映射到这个区域的文件(如果有的话)。(除了内存映射段的例外情况之外,)一个 VMA 是不能匿名映射文件的。上面的每个内存段(比如,堆、栈)都对应一个单个的 VMA。虽然它通常都使用在 x86 的机器上,但它并不是必需的。VMA 也不关心它们在哪个段中。

一个程序的 VMA 在内存描述符中是作为 mmap 域的一个链接列表保存的,以起始虚拟地址为序进行排列,并且在 mm\_rb 域中作为一个 红黑树 的根。红黑树允许内核通过给定的虚拟地址去快速搜索内存区域。在你读取文件 /proc/pid_of_process/maps 时,内核只是简单地读取每个进程的 VMA 的链接列表并显示它们

在 Windows 中,EPROCESS 块大致类似于一个 task\_struct 和 mm\_struct 的结合。在 Windows 中模拟一个 VMA 的是虚拟地址描述符,或称为 VAD;它保存在一个 AVL 树 中。你知道关于 Windows 和 Linux 之间最有趣的事情是什么吗?其实它们只有一点小差别。

4GB 虚拟地址空间被分配到页面中。在 32 位模式中的 x86 处理器中支持 4KB、2MB、以及 4MB 大小的页面。Linux 和 Windows 都使用大小为 4KB 的页面去映射用户的一部分虚拟地址空间。字节 0-4095 在页面 0 中,字节 4096-8191 在页面 1 中,依次类推。VMA 的大小 必须是页面大小的倍数 。下图是使用 4KB 大小页面的总数量为 3GB 的用户空间:

4KB Pages Virtual User Space

处理器通过查看页面表去转换一个虚拟内存地址到一个真实的物理内存地址。每个进程都有它自己的一组页面表;每当发生进程切换时,用户空间的页面表也同时切换。Linux 在内存描述符的 pgd 域中保存了一个指向进程的页面表的指针。对于每个虚拟页面,页面表中都有一个相应的页面表条目(PTE),在常规的 x86 页面表中,它是一个简单的如下所示的大小为 4 字节的记录:

x86 Page Table Entry (PTE) for 4KB page

Linux 通过函数去 读取设置 PTE 条目中的每个标志位。标志位 P 告诉处理器这个虚拟页面是否物理内存中。如果该位被清除(设置为 0),访问这个页面将触发一个页面故障。请记住,当这个标志位为 0 时,内核可以在剩余的域上做任何想做的事。R/W 标志位是读/写标志;如果被清除,这个页面将变成只读的。U/S 标志位表示用户/超级用户;如果被清除,这个页面将仅被内核访问。这些标志都是用于实现我们在前面看到的只读内存和内核空间保护。

标志位 D 和 A 用于标识页面是否是“脏的”或者是已被访问过。一个脏页面表示已经被写入,而一个被访问过的页面则表示有一个写入或者读取发生过。这两个标志位都是粘滞位:处理器只能设置它们,而清除则是由内核来完成的。最终,PTE 保存了这个页面相应的起始物理地址,它们按 4KB 进行整齐排列。这个看起来不起眼的域是一些痛苦的根源,因为它限制了物理内存最大为 4 GB。其它的 PTE 域留到下次再讲,因为它是涉及了物理地址扩展的知识。

由于在一个虚拟页面上的所有字节都共享一个 U/S 和 R/W 标志位,所以内存保护的最小单元是一个虚拟页面。但是,同一个物理内存可能被映射到不同的虚拟页面,这样就有可能会出现相同的物理内存出现不同的保护标志位的情况。请注意,在 PTE 中是看不到运行权限的。这就是为什么经典的 x86 页面上允许代码在栈上被执行的原因,这样会很容易导致挖掘出栈缓冲溢出漏洞(可能会通过使用 return-to-libc 和其它技术来找出非可执行栈)。由于 PTE 缺少禁止运行标志位说明了一个更广泛的事实:在 VMA 中的权限标志位有可能或可能不完全转换为硬件保护。内核只能做它能做到的,但是,最终的架构限制了它能做的事情。

虚拟内存不保存任何东西,它只是简单地 映射 一个程序的地址空间到底层的物理内存上。物理内存被当作一个称之为物理地址空间的巨大块而由处理器访问。虽然内存的操作涉及到某些总线,我们在这里先忽略它,并假设物理地址范围从 0 到可用的最大值按字节递增。物理地址空间被内核进一步分解为页面帧。处理器并不会关心帧的具体情况,这一点对内核也是至关重要的,因为,页面帧是物理内存管理的最小单元。Linux 和 Windows 在 32 位模式下都使用 4KB 大小的页面帧;下图是一个有 2 GB 内存的机器的例子:

Physical Address Space

在 Linux 上每个页面帧是被一个 描述符几个标志 来跟踪的。通过这些描述符和标志,实现了对机器上整个物理内存的跟踪;每个页面帧的具体状态是公开的。物理内存是通过使用 Buddy 内存分配 (LCTT 译注:一种内存分配算法)技术来管理的,因此,如果一个页面帧可以通过 Buddy 系统分配,那么它是未分配的(free)。一个被分配的页面帧可以是匿名的、持有程序数据的、或者它可能处于页面缓存中、持有数据保存在一个文件或者块设备中。还有其它的异形页面帧,但是这些异形页面帧现在已经不怎么使用了。Windows 有一个类似的页面帧号(Page Frame Number (PFN))数据库去跟踪物理内存。

我们把虚拟内存区域(VMA)、页面表条目(PTE),以及页面帧放在一起来理解它们是如何工作的。下面是一个用户堆的示例:

Physical Address Space

蓝色的矩形框表示在 VMA 范围内的页面,而箭头表示页面表条目映射页面到页面帧。一些缺少箭头的虚拟页面,表示它们对应的 PTE 的当前标志位被清除(置为 0)。这可能是因为这个页面从来没有被使用过,或者是它的内容已经被交换出去了。在这两种情况下,即便这些页面在 VMA 中,访问它们也将导致产生一个页面故障。对于这种 VMA 和页面表的不一致的情况,看上去似乎很奇怪,但是这种情况却经常发生。

一个 VMA 像一个在你的程序和内核之间的合约。你请求它做一些事情(分配内存、文件映射、等等),内核会回应“收到”,然后去创建或者更新相应的 VMA。 但是,它 并不立刻 去“兑现”对你的承诺,而是它会等待到发生一个页面故障时才去 真正 做这个工作。内核是个“懒惰的家伙”、“不诚实的人渣”;这就是虚拟内存的基本原理。它适用于大多数的情况,有一些类似情况和有一些意外的情况,但是,它是规则是,VMA 记录 约定的 内容,而 PTE 才反映这个“懒惰的内核” 真正做了什么。通过这两种数据结构共同来管理程序的内存;它们共同来完成解决页面故障、释放内存、从内存中交换出数据、等等。下图是内存分配的一个简单案例:

Example of demand paging and memory allocation

当程序通过 brk() 系统调用来请求一些内存时,内核只是简单地 更新 堆的 VMA 并给程序回复“已搞定”。而在这个时候并没有真正地分配页面帧,并且新的页面也没有映射到物理内存上。一旦程序尝试去访问这个页面时,处理器将发生页面故障,然后调用 do\_page\_fault()。这个函数将使用 find\_vma()搜索 发生页面故障的 VMA。如果找到了,然后在 VMA 上进行权限检查以防范恶意访问(读取或者写入)。如果没有合适的 VMA,也没有所尝试访问的内存的“合约”,将会给进程返回段故障。

找到了一个合适的 VMA,内核必须通过查找 PTE 的内容和 VMA 的类型去处理故障。在我们的案例中,PTE 显示这个页面是 不存在的。事实上,我们的 PTE 是全部空白的(全部都是 0),在 Linux 中这表示虚拟内存还没有被映射。由于这是匿名 VMA,我们有一个完全的 RAM 事务,它必须被 do\_anonymous\_page() 来处理,它分配页面帧,并且用一个 PTE 去映射故障虚拟页面到一个新分配的帧。

有时候,事情可能会有所不同。例如,对于被交换出内存的页面的 PTE,在当前(Present)标志位上是 0,但它并不是空白的。而是在交换位置仍有页面内容,它必须从磁盘上读取并且通过 do\_swap\_page() 来加载到一个被称为 major fault 的页面帧上。

这是我们通过探查内核的用户内存管理得出的前半部分的结论。在下一篇文章中,我们通过将文件加载到内存中,来构建一个完整的内存框架图,以及对性能的影响。


via: http://duartes.org/gustavo/blog/post/how-the-kernel-manages-your-memory/

作者:Gustavo Duarte 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

Torrents(种子):你需要知道的一切事情

Torrents(种子) — 每次听到这个词时,在我的脑海里想到的唯一的事情就是免费的电影、游戏、和被破解的软件。但是我们并不知道它们是如何工作的,在“种子”中涉及到各种概念。因此,通过这篇文章我们从技术的角度来了解种子下载是什么。

“种子”是什么?

“种子”是一个到因特网上文件位置的链接。它们不是一个文件,它们仅仅是动态指向到你想去下载的原始文件上。

例如:如果你点击 Google Chrome,你可以从谷歌的服务器上下载 Google Chrome 浏览器。

如果你明天、或者下周、或者下个月再去点击那个链接,这个文件仍然可以从谷歌服务器上去下载。

但是当我们使用“种子”下载时,它并没有固定的服务器。文件是从以前使用“种子”下载的其它人的个人电脑上下载的。

Torrents 是如何工作的?

Peer to peer network

假设 ‘A’ 上有一些视频,它希望以“种子”方式去下载。因此,他创建了一个“种子”,并将这个链接发送给 ‘B’,这个链接包含了那个视频在因特网上的准确 IP 地址的信息。因此,当 ‘B’ 开始下载那个文件的时候,‘B’ 连接到 ‘A’ 的计算机。在 ‘B’ 下载完成这个视频之后,‘B’ 将开始做为种子,也就是 ‘B’ 将允许其它的 ‘C’ 或者 ‘D’ 从 ‘B’ 的计算机上下载它。

因此每个人先下载文件然后会上传,下载的人越多,下载的速度也越快。并且在任何情况下,如果想停止上传,也没有问题,随时可以。这样做并不会成为什么问题,除非很多的人下载而上传的人很少。

播种者和索取者

kickass torrents website

下载完成特定文件的用户将会即刻做为上传者,因此,可以被新用户下载的已下载者称为播种者。

而一些还没有完成特定文件的下载者,并且还正在下载的用户称为索取者。

qbittorrent software for linux

所有的“种子”文件都独立分割成固定大小的数据包,因此,它们可以非线性顺序和随机顺序下载。每个块都有唯一的标识,因此,一旦所有的块下载完成之后,它们会被拼接出原始文件。

正是因为这种机制,如果你正在从某人处下载一个文件,假如这个时候因某些原因他停止了上传,你可以继续从其它的播种者处继续下载,而不需要从头开始重新下载。

对端

对端是指当前连接到你的索取者。一个正在上传的索取者,不论它下载了多少块,它就是一个对端。

例如:

一个已经下载了文件的前 50 个块的用户就是一个索取者,但是,他又同时上传这些文件,而你只有前 10 个块,因此,你可以从他那里下载最多 50 个块。这时候他就成了你的对端。

最佳实践

当你下载一个“种子”时,总是选择最大的播种者。这就是最佳经验。

这里并没有最小的标准,但是只要确保你选择的是最大的那一个播种者就可以了。

“种子”相关的法律

piracy is illegal

“种子”相关的法律和其它的法律并没有什么区别,对受版权保护的其它任何东西一样,侵权行为会受到法律的制裁。大多数的政府都拦截“种子”站点和协议,但是“种子”下载本身并不是有害的东西。

“种子”对快速分享文件是非常有用的,并且它们被用来共享开源社区的软件,因为它们能节约大量的服务器资源。但是,许多人却因为盗版而使用它们。

结束语

Torrenting 是降低服务器上负载的一个非常完美的技术。“种子”下载可以使我们将下载速度提升到网卡的极限,这是非常好的。但是,在这种非中心化的服务器上,盗版成为一种必然发生的事。限制我们分享的内容,从不去下载盗版的东西,这是我们的道德责任。

请在下面的评论中分享你使用“种子”的心得,分享你喜欢的、法律许可下载的“种子”网站。


via: http://www.linuxandubuntu.com/home/torrents-everything-you-need-to-know

作者:LINUXANDUBUNTU 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

你是否厌倦了 Linux 桌面背景,想要设置好看的壁纸,但是不知道在哪里可以找到?别担心,我们在这里会帮助你。

我们都知道必应搜索引擎,但是由于一些原因很少有人使用它,每个人都喜欢必应网站的背景壁纸,它是非常漂亮和惊人的高分辨率图像。

如果你想使用这些图片作为你的桌面壁纸,你可以手动下载它,但是很难去每天下载一个新的图片,然后把它设置为壁纸。这就是自动壁纸改变的地方。

必应桌面墙纸更换器会自动下载并将桌面壁纸更改为当天的必应照片。所有的壁纸都储存在 /home/[user]/Pictures/BingWallpapers/

方法 1: 使用 Utkarsh Gupta Shell 脚本

这个小型 Python 脚本会自动下载并将桌面壁纸更改为当天的必应照片。该脚本在机器启动时自动运行,并工作于 GNU/Linux 上的 Gnome 或 Cinnamon 环境。它不需要手动工作,安装程序会为你做所有事情。

从 2.0+ 版本开始,该脚本的安装程序就可以像普通的 Linux 二进制命令一样工作,它会为某些任务请求 sudo 权限。

只需克隆仓库并切换到项目目录,然后运行 shell 脚本即可安装必应桌面墙纸更换器。

$ curl https://github.com/UtkarshGpta/bing-desktop-wallpaper-changer/archive/master.zip
$ unzip master
$ cd bing-desktop-wallpaper-changer-master

运行 installer.sh 使用 --install 选项来安装必应桌面墙纸更换器。它会下载并设置必应照片为你的 Linux 桌面。

$ ./installer.sh --install

Bing-Desktop-Wallpaper-Changer
BDWC Installer v3_beta2

GitHub: 
Contributors: 
.
.
[sudo] password for daygeek: ******
.
Where do you want to install Bing-Desktop-Wallpaper-Changer?
   Entering 'opt' or leaving input blank will install in /opt/bing-desktop-wallpaper-changer
   Entering 'home' will install in /home/daygeek/bing-desktop-wallpaper-changer
  Install Bing-Desktop-Wallpaper-Changer in (opt/home)? :Press Enter

Should we create bing-desktop-wallpaper-changer symlink to /usr/bin/bingwallpaper so you could easily execute it?
  Create symlink for easy execution, e.g. in Terminal (y/n)? : y

Should bing-desktop-wallpaper-changer needs to autostart when you log in? (Add in Startup Application)
  Add in Startup Application (y/n)? : y
.
.
Executing bing-desktop-wallpaper-changer...


Finished!!

要卸载该脚本:

$ ./installer.sh --uninstall

使用帮助页面了解更多关于此脚本的选项。

$ ./installer.sh --help

方法 2: 使用 GNOME Shell 扩展

这个轻量级 GNOME shell 扩展,可将你的壁纸每天更改为微软必应的壁纸。它还会显示一个包含图像标题和解释的通知。

该扩展大部分基于 Elinvention 的 NASA APOD 扩展,受到了 Utkarsh Gupta 的 Bing Desktop WallpaperChanger 启发。

特点

  • 获取当天的必应壁纸并设置为锁屏和桌面墙纸(这两者都是用户可选的)
  • 可强制选择某个特定区域(即地区)
  • 为多个显示器自动选择最高分辨率(和最合适的墙纸)
  • 可以选择在 1 到 7 天之后清理墙纸目录(删除最旧的)
  • 只有当它们被更新时,才会尝试下载壁纸
  • 不会持续进行更新 - 每天只进行一次,启动时也要进行一次(更新是在必应更新时进行的)

如何安装

访问 extenisons.gnome.org 网站并将切换按钮拖到 “ON”,然后点击 “Install” 按钮安装必应壁纸 GNOME 扩展。(LCTT 译注:页面上并没有发现 ON 按钮,但是有 Download 按钮)

安装必应壁纸 GNOME 扩展后,它会自动下载并为你的 Linux 桌面设置当天的必应照片,并显示关于壁纸的通知。

托盘指示器将帮助你执行少量操作,也可以打开设置。

根据你的要求自定义设置。


via: https://www.2daygeek.com/bing-desktop-wallpaper-changer-linux-bing-photo-of-the-day/

作者:2daygeek 译者:MjSeven 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出