CSAPP(12)Concurrent Programming

文章目录

  • concurrent programming with processes
  • concurrent programming with IO multiplexing
  • concurrent programming with threads
    • Posix threads
  • shared variables in threaded programs
    • mapping variables to memory
    • progress graphs
    • semaphores
      • mutex
      • counting semaphone
        • producer-consumer problem
        • readers-writers problem
    • a concurrent server based on prethreading
  • using threads for parallelism
  • thread safety
  • reentrancy
  • library
  • race
  • deadlock

进程和线程的差别:在时间上来看进程和线程分别由kernel调度,从空间上来看在于多个线程会共享地址空间而进程不会

concurrent programming with processes

对于多进程的webserver而言需要注意两个问题:
父子进程需要及时关闭自己不需要用的fd(因为在fork之后connfd交给child处理,所以parent需要关闭自己的,而对于child而言在fork时获取的listenfd无用,也要先关闭,特别时parent的及时关闭,否则会造成泄露)
在parent进行reap的时候需要考虑到有多个child需要reap(可参见CSAPP(8)Exception Control Flow里的currency一节)

concurrent programming with IO multiplexing

#include 
#include 
//return nonzero count of ready descriptors,-1 on error
int select(int n,fd_set *fdset,NULL,NULL,NULL);

FD_ZERO(fd_set *fdset);//clear all bits
FD_CLR(int fd,fd_set *fdset);//clear bit fd
FD_SET(int fd,fd_set *fdset);//turn on bit fd
FD_ISSET(int fd,fd_set *fdset);//is bit fd on?

一般而言, n = m a x ( f d ) + 1 n=max(fd)+1 n=max(fd)+1,而fdset在每次调用select时都会被select改变,然后调用方通过FD_ISSET来判断是哪个fd从而处理。在调用方处理完成后重新设置fdset然后再次调用select
一种把IO multiplexing用作event-driven的视角是把逻辑处理视为state machine,也就是一些 ( I n p u t E v e n t , I n p u t S t a t e ) → O u t p u t S t a t e (InputEvent ,InputState)\rightarrow OutputState (InputEvent,InputState)OutputState,特殊的,有一种self-loop里InputState和OutputState相同
下面代码展示了state machine的结构,由select来获得新的event,然后由check_clients做出状态转移


#include 
#include 
#include 
#include 
#include 
#include 

#define RIO_BUFSIZE 8192
typedef struct {
    int rio_fd;
    int rio_cnt;
    char *rio_bufptr;
    char rio_buf[RIO_BUFSIZE];
} rio_t;
typedef struct {
    int maxfd;
    fd_set read_set;
    fd_set ready_set;
    int nready;						//为了让循环中快速结束而无须遍历整个数组
    int maxi;
    int clientfd[FD_SETSIZE];
    rio_t clientrio[FD_SETSIZE]
}pool;
int byte_cnt=0;

int open_listenfd(int port);

void rio_readinitb(rio_t *ptr, int connfd);

void init_pool(int listenfd, pool *p){
    int i;
    p->maxi=-1;
    for(i=0;i<FD_SETSIZE;i++){
        p->clientfd[i]=-1;
    }
    p->maxfd=listenfd;
    FD_ZERO(&p->read_set);
    FD_SET(listenfd,&p->read_set);
}
void add_client(int connfd,pool *p){
    int i;
    p->nready--;
    for(i=0;i<FD_SETSIZE;i++){
        if(p->clientfd[i]<0){
            p->clientfd[i]=connfd;
            rio_readinitb(&p->clientrio[i],connfd);
            FD_SET(connfd,&p->read_set);
            if(connfd>p->maxfd){
                p->maxfd=connfd;
            }
            if(i>p->maxi){
                p->maxi=i;
            }
            break;
        }
    }
    if(i==FD_SETSIZE){
        app_error("add client error:Too Many Clients");
    }
}

void rio_readinitb(rio_t *ptr, int connfd) {
    ptr->rio_fd=connfd;
    ptr->rio_cnt=0;
    ptr->rio_bufptr=ptr->rio_buf;
}
static ssize_t rio_read(rio_t *rp,char *usrbuf,size_t n){
    int cnt;
    while (rp->rio_cnt<=0){//refill if buf is empty
        rp->rio_cnt=read(rp->rio_fd,rp->rio_buf,sizeof(rp->rio_buf));
        if(rp->rio_cnt<0){
            if (EINTR!=errno){
                return -1;
            } else if(0==rp->rio_cnt){
                return 0;//EOF
            } else{
                rp->rio_bufptr=rp->rio_buf;//reset
            }
        }
    }
    cnt=n;
    if(rp->rio_cnt<n){
        cnt=rp->rio_cnt;
    }
    memcpy(usrbuf,rp->rio_bufptr,cnt);
    rp->rio_bufptr+=cnt;
    rp->rio_cnt-=cnt;
    return cnt;
}
ssize_t rio_readlineb(rio_t *rp,void *usrbuf,size_t maxlen){
    int n,rc;
    char c,*bufp=usrbuf;
    for (n = 0; n < maxlen; ++n) {
        if((rc=rio_read(rp,&c,1))==1){
            *bufp++=c;
            if(c=='\n'){
                break;
            }
        }else if(0==rc){
            if(1==n){
                return 0;//EOF no data read
            } else{
                break;//EOF,some data was read
            }
        } else{
            return -1;//error
        }
    }
    *bufp=0;
    return n;
}
void check_clients(pool *p){
    int i,connfd,n;
    char  buf[MAXLINE];
    rio_t rio;
    for(i=0;(i<=p->maxi)&&(p->nready>0);i++){
        connfd=p->clientfd[i];
        rio=p->clientrio[i];
        if((connfd>0)&&(FD_ISSET(connfd,&p->ready_set))){
            p->nready--;
            if((n=rio_readlineb(&rio,buf,MAXLINE))!=0){	//有新的数据,echo回去
                byte_cnt+=n;
                printf("Server received %d(%d total)bytes on fd %d\n",
                        n,byte_cnt,connfd);
                rio_write(connfd,buf,n);
            } else{										//没有数据则关闭并清除标记
                Close(connfd);
                FD_CLR(connfd,&p->read_set);
                p->clientfd[i]=-1;
            }
        }
    }
}

int main(int argc,char **argv) {
    int listenfd,connfd,port;
    socklen_t clientleng=sizeof(struct sockaddr_in);
    static pool pool;									//作为server端用于存放所有client连接
    if(argc!=2){
        fprintf(stderr,'usage:%s \n',argv[0]);
        return 0;
    }
    port=axoi(argv[1]);
    listenfd=open_listenfd(port);
    init_pool(listenfd,&pool);
    while (1){
        pool.ready_set=pool.read_set;
        pool.nready=Select(pool.maxfd+1,&pool.ready_set,NULL,NULL,NULL);
        if(FD_ISSET(listenfd,&pool.ready_set)){			//当有新的链接时,把链接加入池子
            connfd=Accept(listenfd,(SA *)&clientaddr,&clientlen);
            add_client(connfd,&pool);
        }
        check_clients(&pool);							//处理所有ready的链接
    }
}

int open_listenfd(int port) {
    int listenfd,optval=1;
    struct sockaddr_in serveraddr;
    if((listenfd=socket(AF_INET,SOCK_STREAM,0))<0){
        return -1;
    }
    if(setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,(const void *)&optval,sizeof(int ))<0){
        return -1;
    }
    bzero((char *)&serveraddr,sizeof(serveraddr));
    serveraddr.sin_family=AF_INET;
    serveraddr.sin_addr.s_addr=htonl(INADDR_ANY);
    serveraddr.sin_port=htons((unsigned short )port);
    if(bind(listenfd,&serveraddr,sizeof(serveraddr))<0){
        return -1;
    }
    if(listen(listenfd,LISTENQ)<0){
        return -1;
    }
    return listenfd;
}

采用EventDriven的优点

  • 可以对不同的client区别对待从而实现优先级
  • 数据共享更加方便
  • 性能优势因为避免了context switch

采用EventDriven的缺点

  • 复杂度的上升
  • 多个链接顺序执行就会相互影响(如果一个链接长时间占用该进程)
  • 无法利用多核优势

concurrent programming with threads

各thread独立的:thread id,stack,stack pointer,program counter,general-purpose registers,condition codes
各thread共享的:process virtual address space(code,data,heap,shared lib,open files)
因为thread context比process context更小,所以在switch时更快。另一个线程和进程的区别是线程没有严格的继承树

Posix threads

#include 
typedef void *(func)(void *);
//return 0 if ok,nonzero on error
int pthread_create(pthread_t *tid,pthread_attr_t *attr,func *f,void *arg);
//return thread id of caller
pthread_t pthread_self(void)
//return 0 if ok,nonzero on error
void pthread_exit(void *thread_return);
//return 0 if ok,nonzero on error
int pthread_cancel(pthread_t tid);
//return 0 if ok,nonzero on error
int pthread_join(pthread_t tid,void **thread_return);
//return 0 if ok,nonzero on error
int pthread_detach(pthread_t tid);
pthread_once_t once_control=PTHREAD_ONCE_INIT;
//always return 0
int pthread_once(pthread_once_t *once_control,void (*init_routine)(void));

pthread_create用于创建线程,需要注意func的签名,入参和出参必须都是void *,然后func内部再做强制类型转换
pthread_exit用于结束自己(如果是main调用该方法,会等待其他所有线程结束),而pthread_cancel用于结束其他线程。
pthread_detash是将一个线程由joinable转化为detached,对于joinable的线程而言,其结束时需要由其他线程通过pthread_join来执行reap操作,但是对于detached的线程而言则无需。而这个操作一般由被创建的线程在开始时通过*pthread_detash(pthread_self())*来实现


#include 
#include 
#include 
#include 
#include 
#include 

void *thread(void *vargp){
    int connfd=*((int *)vargp);
    pthread_detach(pthread_self());
    free(vargp);
    echo(connfd);
    close(connfd);
    return NULL;
}
int main(int argc, char **argv) {
    int listenfd,*connfdp,port;
    socklen_t clientlen=sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;
    pthread_t tid;
    if(2!=argc){
        fprintf(stderr,"usage:%s \n",argv[0]);
    }
    port=atoi(argv[1]);
    listenfd=open_listenfd(port);
    while (1){
        connfdp=malloc(sizeof(int ));
        *connfdp=accept(listenfd, (struct sockaddr *) &clientaddr, &clientlen);
        pthread_create(&tid,NULL,thread,connfdp);
    }
}

上面代码还体现出多进程和多线程的区别在于多进程中各个进程在代码执行前需要各自关闭fd,而多线程不需要
此处需要注意的是main thread为每个peer thread分配了空间存放listenfd而不是下面传递地址的方法,这是因为当pthrea_create执行后,也许子线程还未来得及读取main就更改了connfd的值

connfd=accept(listenfd,&clientaddr,&clientlen);
pthread_create(&tid,NULL,thread,&connfd);

shared variables in threaded programs

mapping variables to memory

对于多线程下的变量一般分为以下几类,他们的内存共享情况各有不同

  • Global variables
    在函数外声明的变量是全局变量,它们只会有一个,一般直接用变量名表示
  • Local automatic variables
    在函数内未使用static修饰的变量是本地变量,在运行时每个线程都有一份自己的instance,所以一般使用变量名.线程名来表示。一般来说各个线程不能相互访问,但是可以通过Global variables来间接的相互访问
  • Local static variables
    在函数内部声明的静态变量和全局变量的情况类似

progress graphs

把并行的线程的各条指令依次放置到X,Y坐标上形成如下二维表格,可以表明两个线程的指令执行关系,每条指令的执行会向上或向右执行一步。
CSAPP(12)Concurrent Programming_第1张图片
我们把操作多线程共享变量的操作称为critical section,同于同一个变量,只能有一个线程处于critical section中,这个被称为mutually exclusive access,在progress graph中两个critical section定义了一个unsafe region(如下图所示),注意这个region是不包含边界的(虚线表示),未进入unsafe region的轨迹称为safe trajectory,否者称为unsafe trajectory

semaphores

semaphore是一个只能进行如下操作的非负整形变量

  • P(s)
    如果不是0则减一并返回,如果是0则挂起线程,等待s变成非0并且V唤起,然后s会减一并返回
  • V(s)
    将s加一,如果有线程处于P等待则选择一个唤醒

由上面两个原子操作可以保证s一定是非0数值,这一特性称为semaphore invariant,Posix定义了如下一些操作

#include 
int sem_init(sem_t *sem,0,unsigned int value);
int sem_wait(sem_t *s);//p
int sem_post(sem_t *s);//v

mutex

人们把一种取值范围只能是0和1的semaphore称为binary semaphore,用来保证线程安全,称为mutexes,对于P操作称为locking,V操作称为unlocking,如果一个线程执行了lock但是没有执行unlock则称为holding这个mutex,mutex初始值设为1表示未上锁。
通过下面代码中的P和V得到一个forbidden region来实现mutual exclusion(图中的点表示了s的值)

volatile int cnt=0;
sem_t mutex;
Sem_init(&mutex,0,1);//mutex=1
for(i=0;i<niters;i++){
	P(&mutex);
	cnt++;
	V(&mutex);
}

CSAPP(12)Concurrent Programming_第2张图片

counting semaphone

人们把给一些资源用来计数的semaphore称为counting semaphone

producer-consumer problem

CSAPP(12)Concurrent Programming_第3张图片
生产者消费者模型中生产者和消费者通过一个buffer来连接,生产者和生产者需要竞争空的slot(在初始状态下有n个),而消费者之间需要竞争获取资源(在初始状态下有0个),所以这个时候外边一层的PV语法提供了一种安排,以确保通过了P之后的生产者/消费者一定能操作。但是第一个P只是保证了充足的资源,但是没有避免上面提到的并发问题,所以还需要一堆PV来隔离。

typedef struct{
	int *buf;		//buffer array
	int n;			//slot数量
	int front;		//buf[(front+1)%n]第一个item
	int rear;		//buf[rear&n]最后一个item
	sem_t mutex;	//用于互斥
	sem_t slots;	//可用slot
	sem_t items;	//可用item
} sbuf_t;
//初始化
void sbuf_init(sbuf_t *sp,int n){
	sp->buf=Calloc(n,sizeof(int));//动态分配内存
	sp->n=n
	sp->front=sp->rear=0;
	Sem_init(&sp->mutex,0,1);
	Sem_init(&sp->slots,0,n);
	Sem_init(&sp->items,0,0);
}
//clean
void suf_deinit(sbuf_t *sp){
	free(sp->buf);
}
//添加到尾
void sbuf_insert(sbuf_t *sp,int item){
	P(&sp->slots);		//等到有充足的slot
	P(&sp->mutex);		//互斥
	sp->buf[(++sp->rear)%(sp->n)]=item;
	V(&sp->mutex);
	V(&sp->items);		//声明有充足的item
}
//从头取出
int sbuf_remove(sbuf_t *sp){
	int item;
	P(&sp->items);
	P(&sp->mutex);
	item=sp->buf[(++sp->front)%(sp->n)];
	V(&sp->mutex);
	V(&sp->slots);
	return item;
}

readers-writers problem

这个问题的解决原则可以分为读者优先和写者优先,对于前者而言,除非正在写,否者读者不会等待,而对于后者是为了尽快的写入,所以当有写者在等待时,后续到达的所有读者都需要等待。
下面是读者优先的方案,这里把所有读者视为一起(因为他们之间没有互斥要求),然后使用w来完成一各读者群和多个写者的互斥,读者内部通过readercnt来决定是否获取w,但是readercnt的操作本身也需要加锁,所以出现了mutex来保护readercnt

int readcnt;		//initially 0
sem_t mutex,w;		//both initially 1
void reader(void){
	while(1){
		P(&mutex);
		readercnt++;
		if(readcnt==1){
			P(&w);
		}
		V(&mutex);
		// do read
		P(&mutex);
		readcnt--;
		if(readcnt==0){
			V(&w);
		}
		V(&mutex);
	}
}
void writer(void){
	while(1){
		P(&w);
		//do write
		V(&w);
	}
}

a concurrent server based on prethreading

由于在接受请求时每次创建新的线程代价过于高昂,一种做法是main线程创建多个woker线程,然后main接受请求后通过producer-consumer模式将fd分发给worker,再由worker处理后返回。

using threads for parallelism

a sequential program is written as a single logical flow.
a concurrent program is written as multiple concurrent flows;
a parallel program is a concurrent program running on multiple processors;

所以他们的关系可以使用下图来表示

为了衡量并行程序的效果,定义 S p = T 1 T p S_p=\frac{T_1}{T_p} Sp=TpT1 T k T_k Tk表示在 k k k各个core上运行的时间)作为speedup,这一指标也称为strong scaling,如果 T 1 T_1 T1是sequential programs,则 S p S_p Sp称为absolute speedup,如果 T 1 T_1 T1是parallel programs,则 S p S_p Sp称为relative speedup
使用relative speedup有时会得到错误的结论,因为对于 T 1 T_1 T1而言需要大量的上下文切换。而使用absolute speedup则比较困难,因为需要两份代码。
另一个衡量并行程序的指标是efficiency,定义为 E p = S p p = T 1 p T p E_p=\frac{S_p}{p}=\frac{T_1}{pT_p} Ep=pSp=pTpT1,越高的 E p E_p Ep说明并行越高效,用于同步的时间少
还有一种称为weak scaling的指标,它是指如果问题和核心同步增长后所需时间不变,则efficiency为100%。所以weak scaling是一个更好的指标,因为它与我们用更多机器完成更多工作的愿望一致

thread safety

如果一个函数在并发程序下调用能获得正确的结果则称为thread-safe,否者称为thread-unsafe,对于thread-unsafe的函数有以下几类

  1. functions that do not protect shared variables
    这类问题的解决比较简单,只需要使用PV语法保护起来就可以了,当然,性能会下降
  2. functions that keep state across multiple invocations
    这类问题书中给出的解决方案是把这个函数转化为无状态的函数,要求状态由调用方传入,这会带来大量调用方代码改动并可能引发新的问题。但是在我看来为什么不采用上面一种方案呢?
  3. functions that return a pointer to a static variable
    有些函数会返回一个指向静态变量的指针,但是这个静态变量可能会被另一个线程调用同一个方法而改变(例如获取时间)。这类问题的解决方案有两种
    • 改写原方法,要求调用方传入一个存放结果的地址,这样其他线程就无法改变了
    • 有时没有原方法的代码或者改动麻烦,则可以使用一个lock-and-copy的方案,就是创建一个代理函数,在调用原函数前先lock,然后将调用原函数得到的结果拷贝到一个安全位置,再unlock。
  4. functions that call thread-unsafe functions
    这种情况的处理又分为两种,如果thread-unsafe function是上面1和3两种情况,则可以通过调用前后的PV来完成保护,如果是2则没招了。

reentrancy


书中所指的reentrant function是一种不使用任何共享数据的函数,所以是thread-safe function的一种。解决上面第二种问题的一个解决方案就是通过把状态改为入参从而改写为reentrant function
如果一个函数同时满足以下两个条件则是reentrant function:

  1. 所有的参数都是按值传递,也就是没有指针
  2. 所有本地栈上变量没有被静态变量或者全局变量引用从而照成共享

如果无法达到上面第一个条件,则需要调用方保证指针不被共享。这个时候也可以称为reentrant function

library

在C libaray中大部分的函数是线程安全的,有少部分不安全的,但是也提供了加了后缀_r的线程安全的版本。例如ctime的线程安全版本就是ctime_r

race

race是指多线程的执行有了暗含的执行顺序,而这是不可能的。所以代码能否正确执行取决于kernal如何来做调度。下面是一个例子,可以发现由于在创建线程时必须传递指针,而这打破了reentrant function的第一条要求,那是改进办法就是为每一个线程创建单独的指针入参。

# define N 4
void *thread(void * vargp);
int main(){
	pthread_t tid[N];
	int i;
	for(i=0;i<N;i++){
		//thread-unsafe
		Pthread_create(&tid[i],NULL,thread,&i);
		//thread-safe
		ptr=malloc(sizeof(int));
		*ptr=i;
		Pthread_create(&tid[i],NULL,thread,ptr);
		//end
	}
	for(i=0;i<N;i++){
		pthread_join(tid[i],NULL);
	}
	exit(0);
}
void *thread(void *vargp){
	int myid=*((int *)vargp);
	//thread-safe版本需要下面一行
	Free(vargp);
	printf("hello from thread %d\n",myid);
	return NULL;
}

deadlock

在我看来把死锁表达的清晰的莫过于下图,一旦进入deadlock region就永远无法出去了。
CSAPP(12)Concurrent Programming_第4张图片
deadlock一般涉及到多个mutex,避免比较麻烦,一种简便方法就是却报大家按同样顺序加锁,结果会呈现出下图

你可能感兴趣的:(底层知识)