Proxy Lab的并发设计

Proxy Lab要求我们自己写Makefile,所以用C++ 是完全可行的。

Part A

Part A要求做一个并发的HTTP代理。比较先进的思路可以考虑I/O多路复用、协程、线程池等。而我使用了摆烂的方式:来一个请求就新建一个线程。处理完请求之后直接销毁这个线程。

cpp
#include <thread>
// ......

void do_proxy(int fd); // 处理请求的函数

int main()
{
    // ......
    while (true)
    {
        auto fd{accept(listen_fd)};
        if (fd >= 0)
        {
            std::thread th(do_proxy, fd);
            th.detach();
        }
    }
}

Part B

Part B要求在Part A的基础上加入缓存机制,且空间不够时使用LRU机制驱逐。这意味着:

  • 有的线程会向缓存添加内容;空间不够时它还会删除内容;即这类线程会修改缓存。
  • 有的线程会读缓存。

显然,这是读者—写者问题。我原本想直接把缓存用文件来实现(这样并发控制就能直接甩锅给文件系统了),但后来觉得不行。

我又懒得拿两个锁和一个数字记录读者数目,所以,直接用std::shared_mutex摆烂!

好,下一个问题。缓存里面存什么?显然key是URL,value是指向缓存对象的指针、时间戳以及对象的大小。那缓存就直接设计为一个std::unordered_map<std::string, cache_block>全局变量。cache_block的大致结构如下:

cpp
struct cache_block
{
    指针 ptr;
    对象大小 size;
    上次访问的时间戳 stamp;
};

继续思考。在查缓存的时候干什么?有两种方案。

  • 第一种方案:
    • 读者锁定
    • 用给定的URL调用缓存的std::unordered_map::find()
    • 若查找不到,则读者解锁并返回
    • 若查找到,直接将对应的缓存对象写入tcp套接字,并更新时间戳
    • 读者解锁,返回
  • 第二种方案:
    • 读者锁定
    • 用给定的URL调用缓存的std::unordered_map::find()
    • 若查找不到,则读者解锁并返回{nullptr, 0}
    • 若查找到,则返回{ptr, size}并更新时间戳
    • 读者解锁,返回

第一种方案在数据安全上是没有问题的。但问题在于,临界区真的要这么大吗?(整个网络通信的部分都放在临界区里了。。。)

第二种方案有非常小的临界区,但问题在于,如果发生下面的情况:

  • 线程A在缓存中查找到对象o,返回{ptr, size}。此时读锁定已经解除。
  • 由于奇怪的操作系统调度,线程A被挂起;
  • 线程B、C、D……向缓存写入数据,写的对象太多以至于空间不够了,对象o被从缓存驱逐。
  • 线程A又被调度,此时ptr已经是悬垂指针了。

所以我们希望:只要仍存在对o的引用,那就不要释放o;如果不存在对o的引用,那就一定要释放o。

这不就是垃圾回收吗?但是C++ 又有什么垃圾回收呢?答案是std::shared_ptr,引用计数GC。

cache_block中的ptrstd::shared_ptr<char[]>类型,再来看看上面的情况:

  • 首先,对象o由缓存持有,引用计数为1;
  • 线程A在缓存中查找到对象o,返回{ptr, size}。此时读锁定已经解除。o由缓存和线程A共同持有,引用计数为2。
  • 由于奇怪的操作系统调度,线程A被挂起;
  • 线程B、C、D……向缓存写入数据,写的对象太多以至于空间不够了,对象o被从缓存驱逐。缓存不再持有o,o的引用计数为1,此时o还没有被释放。
  • 线程A又被调度,将o写入tcp套接字。
  • 线程A销毁,ptr被析构,此时o不被任何人持有,内存被释放。

那多个线程同时查找o不会把引用计数搞乱吗?答案是还真不会,因为std::shared_ptr的引用计数是原子变量。

Cppreference:多个线程能在shared_ptr的不同实例上调用所有成员函数(包含复制构造函数与复制赋值)而不附加同步,即使这些实例是同一对象的副本且共享所有权也是如此。

多个进程同时查找o,会同时更新时间戳,造成数据竞争。但我认为这无伤大雅。

cache_block的结构就这么愉快的决定了:

cpp
struct cache_block
{
    std::shared_ptr<char[]> ptr;
    std::size_t size;
    std::size_t stamp;
};

查找缓存使用如下代码:

cpp
std::pair<std::shared_ptr<char[]>, std::size_t>
cache::find_cache(const std::string &uri)
{
    std::shared_lock lock(mtx);
    if (auto it{map.find(uri)}; it != map.end())
    {
        auto [ptr, size, _]{it->second};
        it->second.stamp = ++clock;
        return {ptr, size};
    }
    return {nullptr, 0};
}

其中,mapstd::unordered_map<std::string, cache_block>mtxstd::shared_mutexclockstd::atomic_size_t(要考虑多线程同时查缓存的情况)。

Part C

Part C要求处理HTTPS流量。HTTPS是加密的,不可能被缓存,所以Part C实际上简单了很多。略!

其他:文件描述符的优雅释放?

你是否觉得代码里到处都是close()很不雅观?你是否害怕return之后忘记释放文件描述符?只需要使用RAII就能解决这个问题!

cpp
class fd_wrapper
{
private:
    int m_fd{-1};

public:
    fd_wrapper() = default;
    fd_wrapper(int fd) : m_fd{fd} {}
    ~fd_wrapper()
    {
        if (m_fd >= 0)
            close(m_fd);
    }

    fd_wrapper(const fd_wrapper &) = delete;

    fd_wrapper(fd_wrapper &&other) { std::swap(m_fd, other.m_fd); }

    bool valid() const { return m_fd >= 0; }
    int get_fd() { return m_fd; }
};
Todo List
Valaxy v0.28.4 驱动|主题-Yunv0.28.4