在Proxy Lab中,我们会自己实现一个代理服务器。这涉及到网络(教材第十一章)、并发(教材第十二章)的相当多概念。建议大家在阅读Lab指导的时候,反复回顾教材上的相关内容。
proxy究竟是个啥?
proxy是指代理服务器。在各种浏览器设置选项中,都可以看到代理服务器这一项。简单来说,代理在客户端和服务器之间起到桥梁的作用。一方面,代理服务器接收客户端的请求,并转发给真正的服务器;另一方面,代理服务器从真正的服务器接收响应,并转发回客户端。
在handout中,我们可以看到一个tiny
文件夹。这是书上11.6节实现的TINY Web服务器。Proxy Lab就利用这个服务器来测试我们的proxy。我们知道,代理服务器是实际上与客户端直接通信的服务器,因此其框架和Tiny是相似的。所以我们直接复制Tiny的代码,稍加修改并添加并发和缓存功能即可。
前置知识回顾
网络
教材P652这张图非常重要,基本涵盖了第十一章的核心内容,也给出了Proxy Lab的基本框架。
首先来看看教材上实现的两个封装函数:open_cliendfd
和open_listenfd
。
/********** 伪码 **********/
int open_clientfd(char* hostname, char* port) {
listp <- getaddrinfo(hostname, port);
for each p in listp {
clientfd = socket();
connect(clientfd);
if (success)return clientfd;
}
}
- 客户端调用
open_clienfd
,尝试与主机名为hostname
,监听端口为port
的服务器建立连接。 - 首先使用Linux提供的
getaddrinfo
函数,生成套接字地址(socket address)的链表,即listp
。 - 遍历
listp
,依次尝试socket
(创建套接字描述符),connect
(建立连接)。如果成功了,那么clientfd
就是一个成功打开的网络套接字。对于客户端来说,这就相当于一个普通的文件,可以用系统IO函数读写(在这里,Linux“万物皆文件”的思想很大程度上简化了网络编程)。
/********** 伪码 **********/
int open_listenfd(char* port) {
listp <- getaddrinfo(port);
for each p in listp {
listenfd = socket();
bind(listenfd);
if (success){
listen(listenfd);
return listenfd;
}
}
}
- 服务器调用
open_listenfd
,尝试在端口port
上进行监听。 - 同上使用
getaddrinfo
生成listp
。 - 遍历
listp
,依次尝试socket
(创建套接字描述符),bind
(将套接字地址绑定到套接字描述符listenfd
)。 - 如果成功,就调用
listen
将listenfd
转化为监听描述符。此时,服务器已经开始在listenfd
上监听客户端的请求,调用accept
成功后便可得到已连接描述符。
现在我们可以写出proxy的基本框架了。以简单的迭代(非并发)服务器为例:
/********** 伪码 **********/
int main() {
listenfd = open_listenfd(port);
while(1) {
connfd = accept(listenfd);
doit(connfd);
close(connfd);
}
}
以上代理服务器与客户端建立了连接。
/********** 伪码 **********/
void doit(int connfd){
read http request from connfd;
clientfd = open_clientfd();
generate and send http request to clientfd;
while (read http response from clientfd) {
cache http response (optional);
write http response back to connfd;
}
}
以上代理服务器与真正的服务器建立了连接并转发请求,随后将相应传回客户端。
并发
迭代服务器的显著缺点是,一次只服务一个客户端。对此我们有许多改进方案。教材上详细讲述了三种:基于进程(process)、基于线程(thread)、IO多路复用。这里我们使用多线程的方法。
/********** 伪码 **********/
int main() {
listenfd = open_listenfd(port);
while(1) {
connfd = accept(listenfd);
pthread_create(thread, connfd);
}
}
void* thread(void* vargp) {
pthread_detach(pthread_self());
doit(connfd);
close(connfd);
}
- 当代理服务器与一个客户端建立连接后,它并不直接与客户端交互,而是创建一个新的线程去服务这个客户端。
- 创建新的线程后,内核调度新线程时会执行线程例程(thread routine)
thread
。 thread
首先将自己从主线程“分离”出去,即主线程不需要显式地回收它,内核会在它结束时自动回收。- 代理服务器在这个线程中像之前一样执行各种操作。
基于多线程的代理服务器看起来非常简单。但是由于线程间共享变量的存在和内核调度线程的不确定性,多线程会带来很多细微的问题,稍有不慎就会出Bug。
举一个很简单的例子。向线程例程thread
传参的时候,如果直接传递主线程变量connfd
的地址,就会导致问题。这样,主线程创建的多个线程共享这个变量,访问次序的不确定性会导致各个线程取到的值错乱。解决方法是为每个线程传参时单独malloc一块来避免共享。
在Proxy Lab的Part C中,我们要添加缓存功能。显然本地缓存的内容是各个线程共享的,因此我们要谨慎地加锁来保护共享变量。这里我们使用读者写者模型(教材P707)。
pthread.h
库提供了读写锁,具体用法可以查阅文档,这里列举几个:
- 定义读写锁:
pthread_rwlock_t p;
- 初始化:
pthread_rwlock_init(&p, NULL);
- 销毁:
pthread_rwlock_destroy(&p);
- 加读锁:
pthread_rwlock_rdlock(&p);
- 加写锁:
pthread_rwlock_wrlock(&p);
- 释放锁:
pthread_rwlock_unlock(&p);
像书上用信号量和计数变量实现也可,稍麻烦一些。
处理HTTP事务
其实我们回顾前置知识的时候已经搭好了proxy的框架。现在我们来关注doit
函数。
- 从客户端读取请求行(request line)。例如:
GET http://pku.edu.cn HTTP/1.0 - 分割URL,得到
hostname
,port
,filename
。以上面的请求为例,hostname=“http://pku.edu.cn”,port=“80”(默认),filename="/"(默认)。实现在parse_uri
中。 - 调用
open_clientfd
建立与服务器hostname
的port
端口的连接。 - 转发请求行:GET <filename> HTTP/1.0
- 转发请求报头(request header)。按照要求来即可(需要仔细阅读实验指导)。实现在
generate_requesthdrs
中。 - 接收并写回HTTP响应。
代码如下。其中实现缓存功能的语句请参考下文。
void generate_requesthdrs(rio_t *rp, char *req_buf, char *hostname);
void parse_uri(char *uri, char *hostname, char *port, char *filename);
/*
* doit - handle one HTTP request/response transaction
*/
void doit(int fd, size_t t)
{
char buf[MAXLINE];
rio_t rio_client, rio_server;
/* Read request line and headers */
Rio_readinitb(&rio_client, fd);
/* read http request line */
/* eg: GET http: //www.cmu.edu:8080/hub/index.html HTTP/1.1 */
if (!Rio_readlineb(&rio_client, buf, MAXLINE))
return;
char method[MAXLINE], url[MAXLINE], version[MAXLINE];
if (3 != sscanf(buf, "%s %s %s", method, url, version))
return;
if (strcasecmp(method, "GET"))
{
clienterror(fd, method, "501", "Not Implemented",
"do not implement this method");
return;
}
/* eg:
hostname: http: //www.cmu.edu
port: 8080
filename: /hub/index.html
*/
char hostname[MAXLINE], port[MAXLINE], filename[MAXLINE];
/* Parse URI from GET request */
parse_uri(url, hostname, port, filename);
/* find the source in cache */
if (read_cache(url, fd, t))
return;
/* connect with the server */
int connfd;
if ((connfd = Open_clientfd(hostname, port)) < 0)
return;
Rio_readinitb(&rio_server, connfd);
char req_buf[MAXLINE << 1];
/* request line */
sprintf(req_buf, "GET %s HTTP/1.0\r\n", filename);
/* request header */
generate_requesthdrs(&rio_client, req_buf, hostname);
Rio_writen(connfd, req_buf, MAXLINE);
ssize_t len, cachelen = 0;
char *cachebuf = malloc(sizeof(char) * MAX_OBJECT_SIZE);
while ((len = Rio_readlineb(&rio_server, buf, MAXLINE)) > 0)
{
if (cachelen + len < MAX_OBJECT_SIZE)
memcpy(cachebuf + cachelen, buf, len);
cachelen += len;
Rio_writen(fd, buf, len);
}
/* try to cache */
if (cachelen < MAX_OBJECT_SIZE)
write_cache(url, cachebuf, cachelen, t);
free(cachebuf);
Close(connfd);
}
添加缓存功能
cache数据结构
/* Recommended max cache and object sizes */
#define MAX_CACHE_SIZE 1049000
#define MAX_OBJECT_SIZE 102400
#define CACHE_BLOCK_NUM 10
typedef struct
{
int empty;
size_t lru;
size_t size;
pthread_rwlock_t rwlock;
char *obj;
char *url;
} cache_t;
cache_t cache[CACHE_BLOCK_NUM];
推荐的单个缓存大小为MAX_OBJECT_SIZE
(100KB),总缓存大小为MAX_CACHE_SIZE
(约1MB)。方便起见,就设10个缓存位置(当然可以像MallocLab一样采用更节省空间的方式,但相信做过Malloc的同学都不会想再做一次)。
cache包括索引url
和内容obj
(大小为size
),读写锁rwlock
,空闲标记empty
(当然这个可以用size
来判断),LRU(Least Recently Used)策略的计数lru
。
缓存操作
void init_cache();
void free_cache();
int find_cache(char *url)
{
int find = -1;
for (int i = 0; i < CACHE_BLOCK_NUM; ++i)
{
pthread_rwlock_rdlock(&cache[i].rwlock);
if (!cache[i].empty && strcmp(cache[i].url, url) == 0)
{
find = i;
}
pthread_rwlock_unlock(&cache[i].rwlock);
if (find != -1)
break;
}
return find;
}
int read_cache(char *url, int clientfd, size_t t)
{
int num = find_cache(url);
/* not found */
if (num == -1)
return 0;
pthread_rwlock_rdlock(&cache[num].rwlock);
int flag = strcmp(url, cache[num].url);
/* covered after find */
if (flag != 0)
return 0;
pthread_rwlock_unlock(&cache[num].rwlock);
/* try updating lru */
/* no reader, add write lock and write url */
if (!pthread_rwlock_trywrlock(&cache[num].rwlock))
{
cache[num].lru = t;
pthread_rwlock_unlock(&cache[num].rwlock);
}
/* read obj and send to the client */
pthread_rwlock_rdlock(&cache[num].rwlock);
Rio_writen(clientfd, cache[num].obj, cache[num].size);
pthread_rwlock_unlock(&cache[num].rwlock);
return 1;
}
int find_evict()
{
size_t minn = 1 << 30;
int idx = -1;
for (int i = 0; i < CACHE_BLOCK_NUM; ++i)
{
pthread_rwlock_rdlock(&cache[i].rwlock);
if (cache[i].empty)
{
pthread_rwlock_unlock(&cache[i].rwlock);
return i;
}
if (cache[i].lru < minn)
{
minn = cache[i].lru;
idx = i;
}
pthread_rwlock_unlock(&cache[i].rwlock);
}
return idx;
}
void write_cache(char *url, char *obj, size_t size, size_t t)
{
int num = find_evict();
pthread_rwlock_wrlock(&cache[num].rwlock);
strcpy(cache[num].url, url);
memcpy(cache[num].obj, obj, size);
cache[num].lru = t;
cache[num].size = size;
cache[num].empty = 0;
pthread_rwlock_unlock(&cache[num].rwlock);
}
- 重点关注读写锁操作。原则就是读时加读锁,写时加写锁,中途退出的时候不要忘了解锁。
read_cache
先调用find_cache
找到URL对应的缓存块。但是找到缓存块之后可能下一个拿到锁的是写者,可能会修改这个缓存块的内容。因此要再次确认内容无误。- 按照严格的LRU策略,
read_cache
读缓存命中后要更新lru
,但是这是一个写操作,而写者要等到没有读者才能拿到锁。如果每个读命中后都要写,那么大量的写者会饥饿(starvation),更会导致lru
更新顺序错乱。所以我们调用pthread_rwlock_trywrlock
,只在可以即时拿到写锁的情况下更新lru
。
提高健壮性
使用tiny服务器进行的测试其实非常水,通过本地测试的proxy可能完全没法在真正的浏览器上运行。所以我们要尽可能地提高proxy的健壮性(robustness)!
- 使用
memcpy
代替strcpy
。网络内容肯定不止字符串,图片、视频等都是二进制数据,使用strcpy
会出错。 - 调用各种函数时,必须检查返回值。如果返回值指明错误,应当采取恰当的措施。这里直接使用
"csapp.h"
封装的大写字母开头的函数即可。 - 修改发生异常的行为。显然代理服务器不能碰到异常就exit,他应该忍辱负重、心平气和地告诉客户和真正的服务器:出错了,再来一遍吧。所以我们要把
unix_error
等函数里的exit
全去掉,如果有兴趣还可以添加错误提示。 - 对客户端的输入几乎不能有任何假设。在处理输入的时候要加一堆特判。
malloc
以后别忘了free
,打开文件/建立连接以后别忘了close
。长时间运行的服务器会把这些小错误无限放大。- 反复测试,避免并发错误。创建线程、使用读写锁的时候要特别注意。