深入理解计算机系统(CSAPP):Proxy Lab

本文档介绍了如何实现一个代理服务器,涉及网络和并发编程的知识。首先解释了代理服务器的作用,然后回顾了网络编程中的open_clientfd和open_listenfd函数。接着,展示了基于迭代和多线程的并发代理服务器框架,并讨论了线程安全和缓存功能的实现。最后,提到了提高代理服务器健壮性的关键点,如错误处理和内存管理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在Proxy Lab中,我们会自己实现一个代理服务器。这涉及到网络(教材第十一章)、并发(教材第十二章)的相当多概念。建议大家在阅读Lab指导的时候,反复回顾教材上的相关内容。

proxy究竟是个啥?

proxy是指代理服务器。在各种浏览器设置选项中,都可以看到代理服务器这一项。简单来说,代理在客户端和服务器之间起到桥梁的作用。一方面,代理服务器接收客户端的请求,并转发给真正的服务器;另一方面,代理服务器从真正的服务器接收响应,并转发回客户端。
在handout中,我们可以看到一个tiny文件夹。这是书上11.6节实现的TINY Web服务器。Proxy Lab就利用这个服务器来测试我们的proxy。我们知道,代理服务器是实际上与客户端直接通信的服务器,因此其框架和Tiny是相似的。所以我们直接复制Tiny的代码,稍加修改并添加并发和缓存功能即可。

前置知识回顾

网络

教材P652这张图非常重要,基本涵盖了第十一章的核心内容,也给出了Proxy Lab的基本框架。
在这里插入图片描述
首先来看看教材上实现的两个封装函数:open_cliendfdopen_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)。
  • 如果成功,就调用listenlistenfd转化为监听描述符。此时,服务器已经开始在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,得到hostnameportfilename。以上面的请求为例,hostname=“http://pku.edu.cn”,port=“80”(默认),filename="/"(默认)。实现在parse_uri中。
  • 调用open_clientfd建立与服务器hostnameport端口的连接。
  • 转发请求行: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。长时间运行的服务器会把这些小错误无限放大。
  • 反复测试,避免并发错误。创建线程、使用读写锁的时候要特别注意。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值