20155203 《信息安全系统设计基础》第十四周学习总结
再次深入学习第12章——并发编程
深入理解进程和线程
任务(task) 是最抽象的,是一个一般性的术语。
指由软件完成的一个活动。一个任务既可以是一个进程,也可以是一个线程。
简而言之,它指的是一系列共同达到某一目的的操作。
例如,读取数据并将数据放入内存中。
这个任务可以作为一个进程来实现,也可以作为一个线程(或作为一个中断任务)来实现。
这里主要参考博客 进程与线程的一个简单解释
- 概念上
- 进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位;
- 线程是进程的一个实体,是CPU调度和分派的基本单位。它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),一个线程可以创建和撤销另一个线程;
- 进程与线程区别与联系
内核调度的对象是线程,而不是进程。这句话的前提是一个进程只包含一个线程。在当前操作系统中,一个进程都是包括多个线程的
- 划分尺度:线程更小,所以多线程程序并发性更高;
- 资源分配:进程是资源分配的基本单位,同一进程内多个线程共享其资源;
- 地址空间:进程拥有独立的地址空间,同一进程内多个线程共享其资源;
- 处理器调度:线程是处理器调度的基本单位;
- 执行:每个线程都有一个程序运行的入口,顺序执行序列和程序的出口,但线程不能单独执行,必须组成进程,一个进程至少有一个主线程。简而言之,一个程序至少有一个进程,一个进程至少有一个线程.
- linux上的进程与线程
- 一般来说,线程是windows上的概念,windows区分进程和线程。而在linux(老版本)上,统一叫进程,进程是完成某项任务所需资源的集合,同时也是linux基本的执行单元。
- Linux线程是通过进程来实现。Linux kernel为进程创建提供一个clone()系统调用,clone的参数包括如 CLONE_VM, CLONE_FILES, CLONE_SIGHAND 等。通过clone()的参数,新创建的进程,也称为LWP(Lightweight process)与父进程共享内存空间,文件句柄,信号处理等,从而达到创建线程相同的目的。
- Linux 2.6的线程库叫NPTL(Native POSIX Thread Library)。POSIX thread(pthread)是一个编程规范,通过此规范开发的多线程程序具有良好的跨平台特性。尽管是基于进程的实现,但新版的NPTL创建线程的效率非常高。一些测试显示,基于NPTL的内核创建10万个线程只需要2秒,而没有NPTL支持的内核则需要长达15分钟。
- 在Linux 2.6之前,Linux kernel并没有真正的thread支持,一些thread library都是在clone()基础上的一些基于user space的封装,因此通常在信号处理、进程调度(每个进程需要一个额外的调度线程)及多线程之间同步共享资源等方面存在一定问题。为了解决这些问题,当年IBM曾经开发一套NGPT(NextGeneration POSIX Threads), 效率比 LinuxThreads有明显改进,但由于NPTL的推出,NGPT也完成了相关的历史使命并停止了开发。
- NPTL的实现是在kernel增加了futex(fast userspace mutex)支持用于处理线程之间的sleep与wake。futex是一种高效的对共享资源互斥访问的算法。kernel在里面起仲裁作用,但通常都由进程自行完成。
- NPTL是一个1×1的线程模型,即一个线程对于一个操作系统的调度进程,优点是非常简单。而其他一些操作系统比如Solaris则是MxN的,M对应创建的线程数,N对应操作系统可以运行的实体。(N<M),优点是线程切换快,但实现稍复杂。
- 引入线程的好处
- 易于调度。
- 提高并发性。通过线程可方便有效地实现并发性。进程可创建多个线程来执行同一程序的不同部分。
- 开销少。创建线程比创建进程要快,所需开销很少。
- 利于充分发挥多处理器的功能。通过创建多线程进程(即一个进程可具有两个或更多个线程),每个线程在一个处理器上运行,从而实现应用程序的并发性,使每个处理器都得到充分运行。
- 内存中
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
void *thread_foo_func(void *);
void *thread_bar_func(void *);
int global = 4;
int main(){
int local = 8;
int foo, bar;
pthread_t fthread, bthread;
foo = pthread_create(&fthread, NULL, thread_foo_func, (void *)&local);
bar = pthread_create(&bthread, NULL, thread_bar_func, (void *)&local);
if (foo != 0 || bar != 0){
printf("thread creation failed.\n");
return -1;
}
foo = pthread_join(fthread, NULL);
bar = pthread_join(bthread, NULL);
if (foo != 0 || bar != 0){
printf("thread join failed.\n");
return -2;
}
char i;
scanf("%c", &i);
return 0;
}
void *thread_foo_func(void *arg){
int foo_local = 16;
printf("address of global %d: %x\n", global, &global);
printf("address of main local %d: %x\n", *(int *)arg, arg);
printf("address of foo local: %x\n", &foo_local);
char i;
scanf("%c", &i);
}
void *thread_bar_func(void *arg){
int bar_local = 32;
printf("address of global %d: %x\n", global, &global);
printf("address of main local %d: %x\n", *(int *)arg, arg);
printf("address of bar local: %x\n", &bar_local);
char i;
scanf("%c", &i);
}
深入学习三种并发程序的构造方法
现在操作系统提供了三种基本的构造并发程序的方法:
1、进程。每个逻辑控制流都是一个进程,由内核来调度和维护;
2、I/O多路复用。
3、线程。
- 基于进程的并发编程
#include "csapp.h"
void echo(int connfd);
void sigchld_handler(int sig)
{
while (waitpid(-1, 0, WNOHANG) > 0)
;
return;
}
int main(int argc, char **argv)
{
int listenfd, connfd, port, clientlen=sizeof(struct sockaddr_in);
struct sockaddr_in clientaddr;
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(0);
}
port = atoi(argv[1]);
Signal(SIGCHLD, sigchld_handler);
listenfd = Open_listenfd(port);
while (1) {
connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
if (Fork() == 0) {
Close(listenfd); /* Child closes its listening socket */
echo(connfd); /* Child services client */
Close(connfd); /* Child closes connection with client */
exit(0); /* Child exits */
}
Close(connfd); /* Parent closes connected socket (important!) */
}
}
代码分析:
首先是本地处于监听状态,然后去接受客户端的链接,一旦链接,父进程关闭创建的链接描述符,子进程关闭父进程的监听描述符。子进程处理操作,然后关闭链接。
父子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但不共享用户地址空间。这是优点也是缺点:一个进程不可能不小心覆盖另一个进程的虚拟存储器;但是为了共享信息,他们必须使用显示的IPC机制,另一个缺点就是,他们往往比较慢,因为进程控制可IPC的开销很高。
UNIX ipc通常指的是所有允许进程和同一台主机上其他进程进行通信的技术。
- 基于I/O多路复用的并发编程
I/O多路复用可以用做并发事件驱动程序的基础,在事件驱动程序中,流是因为某种事件而前进的,一般概念是把逻辑流模型化为状态机。一个状态机就是一组状态、输入事件和转移。
/* $begin select */
#include "csapp.h"
void echo(int connfd);
void command(void);
int main(int argc, char **argv)
{
int listenfd, connfd, port, clientlen = sizeof(struct sockaddr_in);
struct sockaddr_in clientaddr;
fd_set read_set, ready_set;
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(0);
}
port = atoi(argv[1]);
listenfd = Open_listenfd(port);
FD_ZERO(&read_set);
FD_SET(STDIN_FILENO, &read_set);
FD_SET(listenfd, &read_set);
while (1) {
ready_set = read_set;
Select(listenfd+1, &ready_set, NULL, NULL, NULL);
if (FD_ISSET(STDIN_FILENO, &ready_set))
command(); /* read command line from stdin */
if (FD_ISSET(listenfd, &ready_set)) {
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
echo(connfd); /* echo client input until EOF */
}
}
}
void command(void) {
char buf[MAXLINE];
if (!Fgets(buf, MAXLINE, stdin))
exit(0); /* EOF */
printf("%s", buf); /* Process the input command */
}
/* $end select */
代码分析:
select函数处理类型为fd_set的集合,也叫描述符集合。
逻辑上,我们将描述符结合看成一个大小为n的位向量,每n位对应于描述符n。
当且仅当第n位为1时,描述符n才表明是描述符集合的而一个元素。
假设我们只考虑可读的描述符,select函数有两个输入:一个称为读集合的描述符集合和该读集合的技术n。select函数会一直阻塞,直到读集合中至少有一个描述符准备好可以读。
当且仅当一个从该描述符读取一个字节的请求不会阻塞时,描述符k就表示准备好可以读了。
作为一个副作用,select修改了参数fdset指向的fd_set,指明读集合中一个称为准备好集合的子集,这个集合是由读集合中准备好可以读了的描述符组成的。
函数返回值指明了准备好集合的基数。由于这个副作用,每次调用select时都更新读集合。这个副作用在下面程序中的体现就是必须在while(1)循环中每次有描述符集合的重新复制,或者在while循环中存在FD_ZERO(&read_set),然后FD_SET(listenfd,&read_set).
要理解这个函数的思想,是一个n位的向量。在向量的某一位代表的是相应的套接字描述符,当这个可读或者可写的时候,这个向量上对应的位变为0,说明这个描述符不再这个向量中,在再次循环的时候,需要将这个描述符再次添加到这个描述符集合中。
- 这个函数只是展示了多路复用的思想,对于这个程序来说,一旦链接到某个客户端,就会连续会送输入行,直到客户端关闭这个连接中它的那一端。因此,如果你键入一个命令到标准输入,将不会得到相应,直到服务器和客户端之间结束。一个更好的方法是更细粒度的多路复用,服务器每次循环回送一个问本行。
- 基于线程的并发编程
线程的并发编程是上述两种方式的混合,使用了多个逻辑流,但是在一个地址空间中,线程是运行在进程上下文中的逻辑流,线程由内核自动调度。每个线程都有它自己的线程上下文,包括一个唯一的整数线程ID、栈、栈指针、程序计数器,通用目的寄存器和条件码。
#include "csapp.h"
void echo(int connfd);
void *thread(void *vargp);
int main(int argc, char **argv)
{
int listenfd, *connfdp, port, clientlen=sizeof(struct sockaddr_in);
struct sockaddr_in clientaddr;
pthread_t tid;
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(0);
}
port = atoi(argv[1]);
listenfd = Open_listenfd(port);
while (1) {
connfdp = Malloc(sizeof(int));
*connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen);
Pthread_create(&tid, NULL, thread, connfdp);
}
}
/* thread routine */
void *thread(void *vargp)
{
int connfd = *((int *)vargp);
Pthread_detach(pthread_self());
Free(vargp);
echo(connfd);
Close(connfd);
return NULL;
}
代码分析:
多个线程运行单一进程的上下文中,因此共享这个进程虚拟地址空间按的整个内容,包括他的代码、数据、堆、共享库和打开的文件。
线程的执行有别与进程,一个线程上下文切换要比进程上下文切换快得多。和线程相关的还有线程的终止、回收已终止线程的资源、分离线程。
在任何一个时间点上,线程是可结合的或者是可分离的。
一个可结合的线程能够被其他线程收回其资源和杀死。
在被其他线程回收之前,它的存储器资源是没有被释放的。
相反,一个分离的线程时不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放的。
在21~22行,在传递给线程的参数时,是每个进程自己分配的空间按,如果使用的是主线程分配的空间,那么这个线程接受到参数在下一个连接建立之后,就说明这个连接是错误的。
同时分离了每个线程,这样保证线程在终止后会被系统自己回收资源。
- 多线程程序中的共享变量
#include "csapp.h"
#define N 2
void *thread(void *vargp);
char **ptr; /* global variable */
int main()
{
int i;
pthread_t tid;
char *msgs[N] = {
"Hello from foo",
"Hello from bar"
};
ptr = msgs;
for (i = 0; i < N; i++)
Pthread_create(&tid, NULL, thread, (void *)i);
Pthread_exit(NULL);
}
void *thread(void *vargp)
{
int myid = (int)vargp;
static int cnt = 0;
printf("[%d]: %s (cnt=%d)\n", myid, ptr[myid], ++cnt);
}
/* $end sharing */
- 用信号量同步线程
-
线程同步(互斥锁与信号量的作用与区别)
-
信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作(大家都在semtake的时候,就阻塞在哪里)。而互斥锁是用在多线程多任务互斥的,一个线程占用了某一个资源,那么别的线程就无法访问,直到这个线程unlock,其他的线程才开始可以利用这个资源。比如对全局变量的访问,有时要加锁,操作完了,在解锁。有的时候锁和信号量会同时使用的。
也就是说,信号量不一定是锁定某一个资源,而是流程上的概念,比如:有A,B两个线程,B线程要等A线程完成某一任务以后再进行自己下面的步骤,这个任务 并不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。而线程互斥量则是“锁住某一资源”的概念,在锁定期间内,其他线程无法对被保护的数据进 行操作。在有些情况下两者可以互换。
-
互斥量和信号量的区别
- 互斥量用于线程的互斥,信号量用于线程的同步。
这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。
互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源 - 互斥量值只能为0/1,信号量值可以为非负整数。
也就是说,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问。 - 互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。
- 互斥量用于线程的互斥,信号量用于线程的同步。
-
#include "csapp.h"
#define NITERS 200000000
void *count(void *arg);
/* shared counter variable */
unsigned int cnt = 0;
int main()
{
pthread_t tid1, tid2;
Pthread_create(&tid1, NULL, count, NULL);
Pthread_create(&tid2, NULL, count, NULL);
Pthread_join(tid1, NULL);
Pthread_join(tid2, NULL);
if (cnt != (unsigned)NITERS*2)
printf("BOOM! cnt=%d\n", cnt);
else
printf("OK cnt=%d\n", cnt);
exit(0);
}
/* thread routine */
void *count(void *arg)
{
int i;
for (i = 0; i < NITERS; i++)
cnt++;
return NULL;
}
代码分析:
共享变量非常方便,但是引入了同步错误的可能性。
这个错误会引发操作系统中很多很重要的问题:互斥、PV操作等等
相关实践项目博客
本周结对内容
共同学习第6章、第12章,共同解决疑问和代码问题。