本项目基于CS:APP Proxy lab,实现了一个带缓存的并发代理服务器,它将客户的HTTP GET请求通过HTTP/1.0报文转发给目标服务器,并将其响应报文的内容返回给客户
./tiny
包括CS:APP书中提供的小型Web服务器的实现,它可以用于测试我们的代理服务器
free-port.sh
用于输出一个本机的空闲TCP端口,你可以用这个端口作为运行proxy
的参数
driver.sh
用于测试代理服务器的基本功能、并发性与缓存支持
proxy_seq.c
, proxy_cunc.c
, proxy_cunc_poll.c
, proxy_cache_poll.c
为四种代理服务器的实现,分别是单线程无并发的代理服务器、基于多线程的并发代理服务器,基于预先创建线程池的并发代理服务器,以及线程池并发+缓存的代理服务器,其具体设计见下一节。proxy.c
存储了第四个代理服务器的实现代码
cache.c
与cache.h
包括缓存的实现代码
sbuf.c
与sbuf.h
在CS:APP书中提供,包括了实现生产者-消费者模型的代码
csapp.c
与csapp.h
在CS:APP书中提供,包括一系列函数:
- 对若干系统调用的包装,失败时自动发送错误信息
- 对若干库函数的包装,失败时自动发送错误信息
- 若干信号安全的I/O函数的实现以及对其的包装
- 若干健壮的I/O函数的实现(能够应对
read
和write
冗杂的不足只问题)以及对其的包装
需要注意,本项目对csapp.c
进行了一些修改,以避免客户端的错误请求导致代理服务器崩溃的问题
代理服务器的工作非常简单:
首先,它监听来自客户端的HTTP request报文并解析,一方面获取目标服务器的地址与端口号,一边将原来的HTTP request报文转换成自己的HTTP/1.0 request报文。
接下来,它尝试连接目标服务器,然后向连接好的socket中写入HTTP/1.0 request报文。之后它循环地从连接目标服务器的socket中读取字节流,并写入与客户端相连的socket
读到EOF时,循环结束,关闭与服务器相连的socket,以及与客户相连的socket,然后重新开始监听新的客户端
相较于第一个服务器,我们要求该服务器在接受客户端的连接请求并创建连接套接字后,创建一个新的线程去处理有关的工作,而主线程可以一直守候在监听套接字处。
主线程需要连接套接字的描述符传递给新的线程,而新的线程需要自己调用Pthread_detach(pthread_self())
让主线程不必替结束的线程清理资源
相较于第二个服务器,我们要求该服务器预先分配一个线程池,而不是在连接客户端后再创建线程。我们可以使用生产者-消费者模型来理解此时的工作。其中客户端相当于生产者,我们将与客户端相连的socket描述符不断放入缓冲区的槽位;而执行工作的线程相当于消费者,它们不断从槽位中获取描述符,并执行工作
我们使用CS:APP书中提供的SBUF包(sbuf.c
, sbuf.h
)来实现这一缓冲区,以及对缓冲区的若干操作
在第三个服务器的基础上,我们希望该服务器能够拥有缓存功能,可以在不需要查询目标服务器的情况下从缓存中获取要返回给客户端的内容。缓存的替换策略应当接近LRU(但不一定严格实现,稍后说明)
根据书中proxy lab的要求,我们限定缓存的数据载荷总大小不超过1 MiB
,单个缓存块的数据载荷总大小不超过100 KiB
。我们需要考虑有关缓存实现的下述问题
简单地将1 MiB
的总空间切分成10个最大的缓存块(和一个小的剩余块)虽然可行,但是会面临严重的内部碎片问题。另一方面,如果对于每一个写缓存请求,我们都精确地临时切出一个大小匹配的缓存块,那么最后会得到若干大小不同的已分配的缓存块,当我们需要踢出缓存块时这就会导致困难——简单按照LRU可能会踢出一些极小的块而无法腾出足够空间,我们必须权衡块的大小,这样的替换策略可能会非常复杂
因此,利用一种类似malloc实现中“隐式空闲链表”的思想,我们也可以把所有的缓存空间划分成若干个列表,每个列表中缓存块的大小相同。当我们需要写入缓存时,只选择与其大小最匹配的一个列表,然后写入其中的空闲块或是替换满足LRU条件的旧块。
在实现中,我将总的缓存分为6个列表,列表中块的大小和个数如下
- 5个
100 KiB
的块,总计500 KiB
- 5个
50 KiB
的块,总计250 KiB
- 6个
20 KiB
的块,总计120 KiB
- 8个
10 KiB
的块,总计80 KiB
- 10个
5 KiB
的块,总计50 KiB
- 24个
1 KiB
的块,总计2 KiB
首先,缓存块使用请求的URI作为key
,其value
自然就是响应报文的内容。除此之外,还应该标记数据载荷的长度、最后更新的时间
特别地,我们将空闲块的最后更新时间设置为0,这样我们可以将搜索空块和搜索满足LRU条件的最旧块(后者的时间值一定小于其它已分配块)的过程合并起来
缓存块作为多个工作线程的共享对象,对其的访问恰好符合读者写者模型,我们可以使用POSIX
读写锁进行控制
假设我们已经找到了一个匹配块(通过确认该块已分配并匹配URI),接下来的工作有两步:修改时间戳与读取信息。前者需要用写锁控制,后者则用读锁控制。之所以先修改时间戳,是减小其被其他线程用LRU策略踢出的风险
但是需要注意的是,从我们找到匹配块,到上写锁修改时间戳这一时间段内,其它线程完全有可能抢占调度,将我们的匹配块替换成它自己的缓存。因此在获取写锁之后,我们必须检查一下URI字段是否仍然匹配。同理,在释放写锁到获取读锁的过程也有被抢占调度的风险,我们必须在获取读锁后检查URI字段的匹配情况。假如真的被其它线程趁机替换了我们的目标缓存块,就只能返回0表示未能获取缓存
假设我们已经找到了大小合适的块列表(意味着该列表是所有块大小符合条件的列表中,块最小的一个),下面就需要找到空闲块或者按LRU策略最旧的块
在获取目标块之后,我们只需要用写锁保护,然后进行写入,并在写入完毕后释放写锁。
然而另一个值得注意的地方在于,此时的写入并不严格遵循LRU原则。从我们获取目标块后,到获取写锁之前这段时间,其它线程可能抢占调度,并在同一个块写入它的缓存。之后我们的写入就会把这个刚刚更新的块覆盖
如果我们想做到严格LRU的话:
- 就像在读取缓存时所做的那样,获取写锁之后马上检查该块的时间戳是否变化,如果有变化则放弃写入,释放写锁,重新寻找目标块。然而,只要有线程在我们获取目标块后抢占调度并写入该块,我们就会不断地放弃写入重新寻找,并陷入饥饿
- 我们也可以在搜索目标块的时候就先上锁,然后检查timestamp,倘若不符合条件再解锁并检查下一个块。但是,频繁上锁解锁的开销我们也无法承受
实际上,与其严格遵循LRU,不如设法减小上述情形发生的概率:
- 正如已经实现的,将缓存块分为多个列表。这样,几个不同的写入请求很可能因为数据量不同而被导向至不同的列表,而不至于抢占同一个块
- 我们可以先搜索一个列表中的所有空闲块,然后随机的选取一个,而不是总选取第一个空闲块,从而减小多个请求被导向到同一个块的概率。不过,这会增大搜索的时间开销
由于替换新写入的块并不会导致严重的运行错误,只要其概率足够小不影响缓存性能,我们可以不必强行遵循LRU策略
首先,在创建和关闭listenfd
前后,我们需要初始化和释放缓存
之后对于每个执行线程,在解析完request line并获取URI后,应当首先检查缓存中是否由以该URI为key的块,倘若有可以直接从中获取内容返回给客户端
如果缓存未命中,那么在从目标服务器响应报文时,我们应当用一个char
数组把报文内容储存下来,并不断检查内容的大小是否超过了单个缓存块的最大限制。最终,如果没有超过限制,我们才可以放心地将URI、报文的内容及大小传入对应的写入缓存的函数
首先通过make clean
命令将源代码以外的文件清除,然后通过简单的make
命令进行编译,获取可执行文件
如果希望使用CS:APP书中提供的测试工具,可以直接键入./driver.sh
,该工具以./tiny
路径下的服务器作为目标服务器,并检查代理服务器的基本功能、并发性与缓存能力
如果希望自己测试代理服务器,首先用free-port.sh
获取一个空闲的TCP端口号。假如该端口号是4500
,那么我们只需通过./proxy 4500 &
即可让proxy在后台运行。之后,我们可以通过curl
,选择较老的http网站进行测试,例如curl -v http://www.hangzhou.gov.cn/ --proxy http://localhost:4500
。我们也可以在浏览器中设置使用proxy作为代理
- 增加I/O多路复用版本