文章目录

  • 进程态
  • PCB
  • 进程控制
  • 临界区和锁
  • Linux的4种锁机制
  • P-V操作
  • 经典同步问题
  • 进程间通信
    • 系统IPC:
  • 面试题
  • 系统调用
    • fork函数
    • 请你回答一下fork和vfork的区别
    • 有了进程,为什么还要有线程?
    • 单核机器上写多线程程序,是否需要考虑加锁,为什么?
    • 如何修改文件最大句柄数?
  • 协程
  • 僵尸进程
  • 进程与线程的区别
  • 线程间通信
  • 线程间的同步方式,最好说出具体的系统调用
  • 常用线程模型

进程态

  进程在运行过程中会与其它进程共享CPU,所以每个进程在CPU上都是断断续续运行的。因此为了描述进程处于的不同环节,我们定义进程的状态有以下几种:1. 运行状态(Running):进程已经占有CPU。2. 就绪状态(Ready):具备运行条件但由于无CPU,暂时不能运行。3. 阻塞状态(Block)(等待状态Wait):因为等待某项服务完成或信号不能运行的状态。如:等待系统调用,I/O操作,合作进程信号等等。这三个状态在一定条件下会发生转化:比如操作系统依据某种策略从就绪状态进程中挑选某个进程到运行状态;或者是当某个进程的时间片到了,或有更高级别的进程抢占了当前进程的CPU,此时这个进程就会从运行状态转为就绪状态。若某个运行中的进程请求某个服务,这时进程就有可能会进入阻塞态,若服务到来,进程就会从阻塞态转到就绪态

  Linux系统中进程的状态可以分为以下几种:1. 可运行态:可运行态又分为就绪运行两种。就绪TASK_RUNNING在就绪队列中等待调度。运行:正在运行。2. 阻塞(等待)态:阻塞态又分为浅度阻塞深度阻塞浅度阻塞(TASK_INTERRUPTIBLE)(可中断),能被其他进程的信号或时钟唤醒。深度阻塞:TASK_UNINTERRUPTIBLE(不可中断)不能被其他进程通过信号和时钟唤醒。3. 僵死态(TASK_ZOMBIE):进程终止执行,释放大部分资源。4. 挂起态(TASK_STOPPED):进程被挂起。上述概念的定义和运行过程可用下图表示

  • 交换技术:当多个进程竞争内存资源时,会造成内存资源紧张,并且,如果此时没有就绪进程,处理机会空闲,I/0速度比处理机速度慢得多,可能出现全部进程阻塞等待I/O。针对以上问题,提出了两种解决方法:1. 交换技术:换出一部分进程到外存,腾出内存空间。2. 虚拟存储技术:每个进程只能装入一部分程序和数据。在交换技术上,将内存暂时不能运行的进程,或者暂时不用的数据和程序,换出到外存,来腾出足够的内存空间,把已经具备运行条件的进程,或进程所需的数据和程序换入到内存。从而出现了进程的挂起状态:进程被交换到外存,进程状态就成为了挂起状态。

  • 活动阻塞,静止阻塞,活动就绪,静止就绪:1. 活动阻塞:进程在内存,但是由于某种原因被阻塞了。2. 静止阻塞:进程在外存,同时被某种原因阻塞了。3. 活动就绪:进程在内存,处于就绪状态,只要给CPU和调度就可以直接运行。4. 静止就绪:进程在外存,处于就绪状态,只要调度到内存,给CPU和调度就可以运行。从而可以出现以下几种进程状态切换状态:活动就绪 - 静止就绪(内存不够,调到外存);活动阻塞 - 静止阻塞(内存不够,调到外存);执行 - 静止就绪(时间片用完)。

PCB

  操作系统想要实现上述进程管理,定义了一个数据结构:进程控制块(Process Control Block ,PCB):描述进程状态、资源、和与相关进程关系的数据结构PCB是进程的标志。创建进程时创建PCB,进程撤销后PCB同时撤销。有了PCB这个概念之后,进程就可以表示为PCB+程序。PCB中的基本成员变量有以下几种:1. name(ID):进程名称,标识符。2. status:状态。3. next:指向下一个PCB的指针。4. start_addr:程序地址。5. priority:优先级。6. cpu_status:现场保留区(堆栈)。7. comm_info:进程通信。8. process_family:家族。9. own_resource:资源。

  Linux系统中进程控制块PCB可以用一个结构体task_struct描述,包含以下内容:1. 进程状态。2. 调度信息。3. 标识符。4. 内部进程通信信息。5. 链接信息。6. 时间和计时器。7. 文件系统。8. 虚拟内存信息。9. 处理器信息。除此之外,我们还需要了解一些概念,比如进程的上下文Context,进程运行环境,CPU环境。和进程切换过程:换入进程的上下文进入CPU(从栈上来);换出进程的上下文离开CPU(到栈上去)。

进程控制

  进程控制是指在进程生存全期间,对其全部行为的控制。有四个典型的控制行为:

  1. 创建进程功能:创建一个具有指定标识(ID)的进程。主要参数有:进程标识、优先级、进程起始地址、CPU初始状态、资源清单等。创建过程为:创建一个空白PCB,获得并赋予进程标识符ID,为进程分配空间,初始化PCB,将这个进程插入到相应的进程队列中去。进程创建的伪代码可表示为:
Create(Si,Mi,Pi){ // CPU的状态,内存,优先级p = Get_New_PCB(); // 分配新的PCBpid = Get_New_PID(); // 分配进程的PIDp->ID = pid // 设置进程的PIDp->CPU_State = Si; // CPU的状态p->Memory = Mi; // 内存p->Priority = Pi; // 优先级p->Status.Type = "Ready"; // 进程状态p->Status.List = RL; // 进程队列RL:Ready ListInsert(RL,p) // 将进程p插入就绪队列Scheduler(); // 调度程序
}
  1. 阻塞进程。进程阻塞一般出现在以下几种情况中:请求系统服务(由于某种原因,OS不能立即满足进程的要求)。启动某种操作(进程启动某操作,阻塞等待该操作完成)。新数据尚未到达(A进程要获得B进程的中间结果,A进程等待)。无新工作可作(进程完成任务后,自我阻塞,等待新任务到达)。依据不同的阻塞原因构建不同的阻塞队列。阻塞的实现一般分为以下四个步骤:停止运行;将PCB运行态改阻塞态;插入相应原因的阻塞队列;转调度程序。

  2. 撤消进程。进程运行完成之后需要将其从内存中删除,撤销内存,删除PCB。进程正常结束、异常结束、外界干预的情况下都会发生进程撤销。进程撤销时只需要提供被撤销进程的ID号即可。进程撤销的实现主要分为以下几个步骤:在PCB队列中检索出该PCB。获取该进程的状态。若该进程处在运行态,立即终止该进程(递归检查是否有子进程,先撤销子进程)。释放进程占有的资源。将进程从PCB队列中移除。

  3. 唤醒进程功能:唤醒处于阻塞队列当中的某个进程。引起唤醒的时机/事件:系统服务由不满足到满足。I/O完成。新数据到达。进程提出新请求(服务)。唤醒进程同样只需要被唤醒进程的标识即可。上述四个步骤都需要用原语来实现,所谓的原语指:由若干指令构成的具有特定功能的函数,具有原子性,其操作不可分割。

临界区和锁

  若有同一个全局变量i的两个程序并发执行,它们的运行结果往往会相互干扰。解决办法是设定一个特定区域,不让两个程序同时进入。这个特定区域我们称其为临界区(Critical Section),而这种一次仅允许一个进程使用的共享资源称之为临界资源。临界区和临界资源的访问特点是具有排他性;并发进程不能同时进入临界区

  设计临界区必须遵循四个规则:1. 忙则等待:当临界区忙时,其他进程必须在临界区处等待。2. 空闲让进:当无进程处于临界区时,任何有权进程可进入临界区。3. 有限等待:进程进入临界区的请求应在有限时间内得到满足。4. 让权等待:等待进程放弃CPU。(让其它进程有机会得到CPU)。

  通过锁机制能够设置程序互斥进入临界区。首先需要设置一个标志S来表示临界资源是否可用,1表示可用,0表示不可用。

  • 上锁操作:进入临界区之前首先检查标志是否可用。不可用则在临界区之外等待。若为可用,则访问临界资源,同时将标志改为不可用。其步骤如下:
LOCK(S){ // 上锁操作
test: if(S == 0)goto test; // 测试锁标志else // S == 1S = 0 // 上锁
}
  • 开锁操作:退出临界区时将当前标志改为可用。开锁操作步骤如下:
UnLock(S){ // 开锁操作S = 1; // 开锁
}

  上锁操作和开锁操作都需要使用原语来实现。用锁机制访问临界区的步骤如下所示:1. 初始化锁的状态S = 1(可用);2. 进入临界区之前执行上锁Lock(s)操作;3. 离开临界区之后执行开锁unLock(s)操作;

Linux的4种锁机制

  1. 互斥锁mutex,用于保证在任何时刻,都只能有一个线程访问该对象。当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒。
  2. 读写锁rwlock,分为读锁和写锁。处于读操作时,可以允许多个线程同时获得读操作。但是同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。 注意:写锁会阻塞其它读写锁。当有一个线程获得写锁在写时,读锁也不能被其它线程获取;写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。适用于读取数据的频率远远大于写数据的频率的场合。
  3. 自旋锁spinlock,在任何时刻同样只能有一个线程访问对象。但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗,在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源。

两个进程访问临界区资源,会不会出现都获得自旋锁的情况?答:单核cpu,并且开了抢占可以造成这种情况。

  1. RCU:即read-copy-update,在修改数据时,首先需要读取数据,然后生成一个副本,对副本进行修改。修改完成后,再将老数据update成新的数据。使用RCU时,读者几乎不需要同步开销,既不需要获得锁,也不使用原子指令,不会导致锁竞争,因此就不用考虑死锁问题了。而对于写者的同步开销较大,它需要复制被修改的数据,还必须使用锁机制同步并行其它写者的修改操作。在有大量读操作,少量写操作的情况下效率非常高。
  • 互斥锁和读写锁的区别

  读写锁区分读者和写者,而互斥锁不区分。互斥锁同一时间只允许一个线程访问该对象,无论读写。但是读写锁同一时间内只允许一个写者,可以允许多个读者同时读对象。

P-V操作

  • 同步和P-V操作

  由于进程具有异步性,就会导致进程在并发的环境下有一些相互合作的进程,相互有关联的进程相互干扰。导致结果不正确,结果相互干扰。

  进程互斥关系的进程:多个进程由于共享了独占性资源,必须协调各进程对资源的存取顺序:确保没有任何两个或以上的进程同时进行存取操作。具有互斥关系的进程都和资源共享有关系。资源就是临界资源,对资源的存取就是临界区的访问。

  进程的同步关系:若干合作进程为了完成一个共同的任务,需要相互协调运行步伐:一个进程开始某个操作之前必须要求另一个进程已经完成某个操作否则前面的进程只能等待。合作进程中某些操作之间需要满足某种先后关系或某个操作能否进行需要满足某个前提条件,否则只能等待互斥关系属于特殊的同步关系。

  • 信号灯信号灯概念:信号灯是一种卓有成效的进程同步机制。进程在运行过程受信号灯状态控制,并能改变信号灯状态。信号灯的状态可以阻塞唤醒进程。信号灯的状态可以被进程改变。信号灯数据结构:信号灯变量定义为一个二元矢量(S,q)。S代表整数,初值非负(S又称信号量)。q代表PCB队列,初值为空集。
struct SEMAPHORE{int S; // 整数,初值非负pointer_PCB q; // 队列:进程PCB指针,初值空集。
}

  信号灯有两个操作P操作和V操作(PV是荷兰语:Passeren通过,Vrijgeven释放。):

  1. P操作(函数或过程, P(S,q)):S值减1,若差大于或等于零,该进程继续;若差小于零,则该进程阻塞并加入到队列q中,并转调度函数。P操作可能使进程在调度处阻塞。S的初值很重要!
P(S,q){S = S - 1;if(S < 0){Insert(Caller, q);Block(Caller);转调度函数}
}
  1. V操作(函数或过程, V(S,q)):S值加1,若和大于零,该进程继续;若和小于或等于零,该进程继续同时从q中唤醒一个进程。V操作可能会唤醒阻塞的进程。执行V操作之后就释放了一个资源S,此时如果S还是小于等于0的话,说明有进程在阻塞中,这个刚刚释放的资源S就可以用于这个进程,所以当S还小于等于0的时候就唤醒一个进程。
V(S,q){S = S + 1;if(S <= 0){Remove(q,pid); // pid:进程ID。Wakeup(pid);}
}
  • P-V操作解决互斥问题

  P-V操作的本质是要实现对临界区的互斥访问,说白了就是只允许最多一个进程处于临界区。用P-V操作同样可以解决这个问题:进入临界区之前执行P操作,离开临界区之后执行V操作。同步机制的实质是:运行条件不满足时,能让进程暂停。运行条件满足时,能够让进程继续运行P操作可以理解为申请资源操作,V操作可以理解为释放资源操作。

  P-V操作应用于进程同步的基本思路:1. 暂停当前进程:在关键操作之前执行Р操作。必要时可暂停。2. 继续进程:在关键操作之后执行V操作,必要时唤醒合作进程。定义有意义的信号量S,并设置合适的初值。信号量S能明确地表示运行条件

经典同步问题

  • 生产者和消费者:一群生产者(Producer)向一群消费者(Consumer)提供产品(数据),共享缓冲区。有m个生产者,往5个数据的缓冲区中存数据,有k个消费者。这里既不允许生产过多数据到缓冲区,也不允许消费者从缓冲区获取大于5个的数据。

  具体规则为:1. 不能向满缓冲区存产品。2. 不能从空缓冲区取产品。3. 每个时刻仅允许1个生产者或消费者存或取1个产品。

int full = 0; // 信号量:缓冲区中的数据的个数,初值为0。
int empty = 5; // 信号量:缓冲区中的空位的个数,初值5。
int mutex = 1; // 信号量:缓冲区互斥使用,初值1,可用

  生产者:

producer i(){ // i = 1...mwhile(TRUE){生产1个数据;P(empty);P(mutex);存1个数据到缓冲区;V(mutex);V(full);}
}

  消费者:

consumer j(){ // j = 1...kwhile(TRUR){P(full);P(mutex);从缓冲区取1个数据;V(mutex);V(empty);消费/处理数据;}
}

进程间通信

  进程间通信主要包括管道、文件、系IPC(包括消息队列、信号量、信号、共享内存等)、以及套接字socket(socket也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同主机之间的进程通信)。

  • 管道定义:管道是进程间的一种通信机制。一个进程A可以通过管道把数据传输给另外一个进程B。前者A向管道输入数据,后者B从管道读取数据。管道的工作原理:管道像文件一样,可读可写。具有读/写2个句柄。CreatePipe(Handle W,Handle R)
  1. 通过写写句柄(W)向管道中写数据:WriteFile(W, Buffer)。或通过输出重定向向写句柄(W)写入。
  2. 通过读读句柄(R)从管道中读数据:ReadFile(R, Buffer),或通过输入重定向从读句柄(R)读出。

  管道主要包括无名管道命名管道。管道可用于具有亲缘关系的父子进程间的通信。有名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。无名管道只能用于父子或兄弟进程间通信。

管道的通信是单向的,如果父进程与子进程之间要通信的话需要建立两个管道。

  创建进程间的管道主要分为以下几步:父进程A调用CreatePipe(W, R)创建管道,同时指明WR两个读写句柄。接下来父进程调用CreateProccess()创建子进程。父进程写或读管道,子进程输入或输出重定向到管道。

  1. 父进程A调用WriteFile(W,BufferA)时,A创建子进程BCreateProcess(B,将B输入重定向到管道的读句柄R)。这样子进程B可以使用输入重定向使用管道。
  2. 父进程A调用ReadFile(R, Buffer A)时,子进程B只能使用输出重定向的方式来向管道写句柄。
  • 普通管道PIPE
  1. 它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端。
  2. 它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)。
  3. 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
  • 命名管道FIFO
  1. FIFO可以在无关的进程之间交换数据;
  2. FIFO有路径名与之相关联,它以一种特殊备文件形式存在于文件系统中;

系统IPC:

  1. 消息队列

  消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标记。(消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等特点)具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息;

  特点

  • 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。
  • 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。
  • 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
  1. 信号量semaphore

  信号量(semaphore)与已经介绍过的IPC结构不同,它是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。

  特点

  • 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
  • 信号量基于操作系统的PV操作,程序对信号量的操作都是原子操作。
  • 每次对信号量的PV操作不仅限于对信号量值加1或减1,而且可以加减任意正整数。
  • 支持信号量组。
  1. 信号signal

  信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

  1. 共享内存(Shared Memory)

  它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等

  特点:

  1. 共享内存是最快的一种IPC,因为进程是直接对内存进行存取。
  2. 因为多个进程可以同时操作,所以需要进行同步。
  3. 信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。

面试题

  • 对进程可以按以下方式分类:1. 按使用资源的权限分:系统进程:指系统内核相关的进程。用户进程:运行于用户态的进程。2. 按对CPU的依赖性:CPU进程:计算型进程。I/O进程:侧重于I/O的进程。

  • 多进程和多线程的使用场景:1. 多进程模型的优势是CPU。2. 多线程模型主要优势为线程间切换代价较小,因此适用于I/O密集型的工作场景,因此I/O密集型的工作场景经常会由于I/O阻塞导致频繁的切换线程。同时,多线程模型也适用于单机多核分布式场景。

  • 如何实现线程池

  1. 设置一个生产者消费者队列,作为临界资源。
  2. 初始化n个线程,并让其运行起来,加锁去队列取任务运行。
  3. 当任务队列为空的时候,所有线程阻塞。
  4. 当生产者队列来了一个任务后,先对队列加锁,把任务挂在到队列上,然后使用条件变量去通知阻塞中的一个线程。
  • 线程需要保存哪些上下文,SP、PC、EAX这些寄存器是干嘛用的

  线程在切换的过程中需要保存当前线程id、线程状态、堆栈、寄存器状态等信息。其中寄存器主要包括SPPCEAX等寄存器,其主要功能如下:SP:堆栈指针,指向当前栈的栈顶地址。PC:程序计数器,存储下一条将要执行的指令。EAX;累加寄存器,用于加法乘法的缺省寄存器。

  • 如何设计server,使得能够接收多个客户端的请求

  多线程,线程池,io复用。

  • 就绪状态的进程在等待什么?

  被调度使用cpu的运行权。

  • 死循环+来连接时新建线程的方法效率有点低,怎么改进?

  提前创建好一个线程池,用生产者消费者模型,创建一个任务队列,队列作为临界资源,有了新连接,就挂在到任务队列上,队列为空所有线程睡眠。改进死循环:使用select epoll这样的技术。

系统调用

  1. 概念

  在计算机中,系统调用(英语:system call),又称为系统呼叫,指运行在使用者空间的程序向操作系统内核请求需要更高权限运行的服务。系统调用提供了用户程序与操作系统之间的接口(即系统调用是用户程序和内核交互的接口)。

  操作系统中的状态分为管态(核心态)和目态(用户态)。大多数系统交互式操作需求在内核态执行。如设备IO操作或者进程间通信。特权指令:一类只能在核心态下运行而不能在用户态下运行的特殊指令。不同的操作系统特权指令会有所差异,但是一般来说主要是和硬件相关的一些指令。用户程序只在用户态下运行,有时需要访问系统核心功能,这时通过系统调用接口使用系统调用

  应用程序有时会需要一些危险的、权限很高的指令,如果把这些权限放心地交给用户程序是很危险的(比如一个进程可能修改另一个进程的内存区,导致其不能运行),但是又不能完全不给这些权限。于是有了系统调用,危险的指令被包装成系统调用,用户程序只能调用而无权自己运行那些危险的指令。另外,计算机硬件的资源是有限的,为了更好的管理这些资源,所有的资源都由操作系统控制,进程只能向操作系统请求这些资源。操作系统是这些资源的唯一入口,这个入口就是系统调用。

  1. 系统调用举例

  对文件进行写操作,程序向打开的文件写入字符串hello worldopenwrite都是系统调用。还有创建进程forkvfork等都是系统调用。

fork函数

  Fork:创建一个和当前进程映像一样的进程可以通过fork()系统调用:

#include <sys/types.h>
#include <unistd.h>
#include <iostream>
using namespace std;
int main(int argc, const char * argv[]) {int i = 0;cout << "before fork" << endl;pid_t pid = fork();cout << "after fork" << endl;cout << pid << endl;if(pid<0) cout << "there is something error" << endl;else if(pid == 0){while(i<10){cout << "this is child process " << i << endl;i = i + 1;}}else{while(i<10){cout << "this is parent process " << i << endl;i = i + 2;}}return 0;
}

  输出结果为:

before fork
after fork
1803
this is parent process 0
this is parent process 2
this is parent process 4
this is parent process 6
this is parent process 8
after fork
0
this is child process 0
this is child process 1
this is child process 2
this is child process 3
this is child process 4
this is child process 5
this is child process 6
this is child process 7
this is child process 8
this is child process 9

  成功调用fork( )会创建一个新的进程,它几乎与调用fork( )的进程一模一样,这两个进程都会继续运行。在子进程中,成功的fork( )调用会返回0。在父进程中fork( )返回子进程的pid。如果出现错误,fork( )返回一个负值。

  最常见的fork( )用法是创建一个新的进程,然后使用exec( )载入二进制映像,替换当前进程的映像。这种情况下,派生(fork)了新的进程,而这个子进程会执行一个新的二进制可执行文件的映像。这种“派生加执行”的方式是很常见的。

  在早期的Unix系统中,创建进程比较原始。当调用fork时,内核会把所有的内部数据结构复制一份,复制进程的页表项,然后把父进程的地址空间中的内容逐页的复制到子进程的地址空间中。但从内核角度来说,逐页的复制方式是十分耗时的。现代的Unix系统采取了更多的优化,例如Linux,采用了写时复制的方法,而不是对父进程空间进程整体复制。

  • 请你说说fork,wait,exec函数

  父进程产生子进程使fork拷贝出来一个父进程的副本,此时只拷贝了父进程的页表,两个进程都读同一块内存,当有进程写的时候使用写实拷贝机制分配内存,exec函数可以加载一个elf文件去替换父进程,从此父进程和子进程就可以运行不同的程序了。fork从父进程返回子进程的pid,从子进程返回0。调用了wait的父进程将会发生阻塞,直到有子进程状态改变,执行成功返回0,错误返回-1exec执行成功则子进程从新的程序开始运行,无返回值,执行失败返回-1

请你回答一下fork和vfork的区别

  • fork的基础知识fork创建一个和当前进程映像一样的进程可以通过fork( )系统调用:
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

  成功调用fork( )会创建一个新的进程,它几乎与调用fork( )的进程一模一样,这两个进程都会继续运行。在子进程中,成功的fork( )调用会返回0。在父进程中fork( )返回子进程的pid。如果出现错误,fork( )返回一个负值。

  最常见的fork( )用法是创建一个新的进程,然后使用exec( )载入二进制映像,替换当前进程的映像。这种情况下,派生fork了新的进程,而这个子进程会执行一个新的二进制可执行文件的映像。这种“派生加执行”的方式是很常见的。

  在早期的Unix系统中,创建进程比较原始。当调用fork时,内核会把所有的内部数据结构复制一份,复制进程的页表项,然后把父进程的地址空间中的内容逐页的复制到子进程的地址空间中。但从内核角度来说,逐页的复制方式是十分耗时的。现代的Unix系统采取了更多的优化,例如Linux,采用了写时复制的方法,而不是对父进程空间进程整体复制。

  • vfork的基础知识:在实现写时复制之前,Unix的设计者们就一直很关注在fork后立刻执行exec所造成的地址空间的浪费。BSD的开发者们在3.0BSD系统中引入了vfork( )系统调用。
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);

  除了子进程必须要立刻执行一次对exec的系统调用,或者调用_exit( )退出,对vfork( )的成功调用所产生的结果和fork( )是一样的。vfork( )会挂起父进程直到子进程终止或者运行了一个新的可执行文件的映像。通过这样的方式,vfork( )避免了地址空间的按页复制。在这个过程中,父进程和子进程共享相同的地址空间和页表项。实际上vfork( )只完成了一件事:复制内部的内核数据结构。因此,子进程也就不能修改地址空间中的任何内存。

  vfork( )是一个历史遗留产物,Linux本不应该实现它。需要注意的是,即使增加了写时复制,vfork( )也要比fork( )快,因为它没有进行页表项的复制。然而,写时复制的出现减少了对于替换fork( )争论。实际上,直到2.2.0内核,vfork( )只是一个封装过的fork( )。因为对vfork( )的需求要小于fork( ),所以vfork( )的这种实现方式是可行的。

  • 补充知识点:写时复制

  Linux采用了写时复制的方法,以减少fork时对父进程空间进程整体复制带来的开销。

  写时复制是一种采取了惰性优化方法来避免复制时的系统开销。它的前提很简单:如果有多个进程要读取它们自己的那部门资源的副本,那么复制是不必要的。每个进程只要保存一个指向这个资源的指针就可以了。只要没有进程要去修改自己的“副本”,就存在着这样的幻觉:每个进程好像独占那个资源。从而就避免了复制带来的负担。如果一个进程要修改自己的那份资源“副本”,那么就会复制那份资源,并把复制的那份提供给进程。不过其中的复制对进程来说是透明的。这个进程就可以修改复制后的资源了,同时其他的进程仍然共享那份没有修改过的资源。所以这就是名称的由来:在写入时进行复制。

  写时复制的主要好处在于:如果进程从来就不需要修改资源,则不需要进行复制。惰性算法的好处就在于它们尽量推迟代价高昂的操作,直到必要的时刻才会去执行。

  在使用虚拟内存的情况下,写时复制(Copy-On-Write)是以页为基础进行的。所以,只要进程不修改它全部的地址空间,那么就不必复制整个地址空间。在fork( )调用结束后,父进程和子进程都相信它们有一个自己的地址空间,但实际上它们共享父进程的原始页,接下来这些页又可以被其他的父进程或子进程共享。

  写时复制在内核中的实现非常简单。与内核页相关的数据结构可以被标记为只读和写时复制。如果有进程试图修改一个页,就会产生一个缺页中断。内核处理缺页中断的方式就是对该页进行一次透明复制。这时会清除页面的COW属性,表示着它不再被共享。

  现代的计算机系统结构中都在内存管理单元(MMU)提供了硬件级别的写时复制支持,所以实现是很容易的。

  在调用fork( )时,写时复制是有很大优势的。因为大量的fork之后都会跟着执行exec,那么复制整个父进程地址空间中的内容到子进程的地址空间完全是在浪费时间:如果子进程立刻执行一个新的二进制可执行文件的映像,它先前的地址空间就会被交换出去。写时复制可以对这种情况进行优化。

  • forkvfork的区别
  1. fork( )的子进程拷贝父进程的数据段和代码段;vfork( )的子进程与父进程共享数据段
  2. fork( )的父子进程的执行次序不确定;vfork( )保证子进程先运行,在调用execexit之前与父进程数据是共享的,在它调用execexit之后父进程才可能被调度运行。
  3. vfork( )保证子进程先运行,在它调用execexit之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
  4. 当需要改变共享数据段中变量的值,则拷贝父进程。

有了进程,为什么还要有线程?

  1. 线程产生的原因

  进程可以使多个程序能并发执行,以提高资源的利用率和系统的吞吐量;但是其具有一些缺点。像:进程在同一时间只能干一件事。进程在执行的过程中如果阻塞,整个进程就会挂起,即使进程中有些工作不依赖于等待的资源,仍然不会执行。因此,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时空开销,提高并发性

  1. 和进程相比,线程的优势如下

  从资源上来讲,线程是一种非常"节俭"的多任务操作方式。在linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种"昂贵"的多任务工作方式。

  从切换效率上来讲,运行于一个进程中的多个线程,它们之间使用相同的地址空间,而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间。据统计,一个进程的开销大约是一个线程开销的30倍左右。

  从通信机制上来讲,线程间方便的通信机制。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过进程间通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其他线程所用,这不仅快捷,而且方便。

  除以上优点外,多线程程序作为一种多任务、并发的工作方式,还有如下优点:

  1. 使多CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。
  2. 改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序才会利于理解和修改。

单核机器上写多线程程序,是否需要考虑加锁,为什么?

  在单核机器上写多线程程序,仍然需要线程锁。因为线程锁通常用来实现线程的同步和通信。在单核机器上的多线程程序,仍然存在线程同步的问题。因为在抢占式操作系统中,通常为每个线程分配一个时间片,当某个线程时间片耗尽时,操作系统会将其挂起,然后运行另一个线程。如果这两个线程共享某些数据,不使用线程锁的前提下,可能会导致共享数据修改引起冲突。

如何修改文件最大句柄数?

  linux默认最大文件句柄数是1024个,在linux服务器文件并发量比较大的情况下,系统会报"too many open files"的错误。故在linux服务器高并发调优时,往往需要预先调优Linux参数,修改Linux最大文件句柄数。

  有两种方法:

  1. ulimit -n <可以同时打开的文件数>,将当前进程的最大句柄数修改为指定的参数(注:该方法只针对当前进程有效,重新打开一个shell或者重新开启一个进程,参数还是之前的值)

  首先用ulimit -a查询Linux相关的参数,如下所示:

core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 94739
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 94739
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

  其中,open files就是最大文件句柄数,默认是1024个。修改Linux最大文件句柄数: ulimit -n 2048, 将最大句柄数修改为2048个。

  1. 对所有进程都有效的方法,修改Linux系统参数

  vi /etc/security/limits.conf 添加

*  soft  nofile  65536
*  hard  nofile  65536

  将最大句柄数改为65536。修改以后保存,注销当前用户,重新登录,修改后的参数就生效了。

协程

  协程,又称微线程,纤程,英文名Coroutine。协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

def A():print '1'print '2'print '3'
def B():print 'x'print 'y'print 'z'

  由协程运行结果可能是12x3yz。在执行A的过程中,可以随时中断,去执行BB也可能在执行过程中,中断再去执行A。但协程的特点在于,是一个线程执行

  • 协程和线程区别

  那和多线程比,协程最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。

  第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

  • 其他

  在协程上利用多核CPU呢–多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。

  Python对协程的支持还非常有限,用在generator中的yield可以一定程度上实现协程。虽然支持不完全,但已经可以发挥相当大的威力了。

僵尸进程

  1. 正常进程

  正常情况下,子进程是通过父进程创建的,子进程再创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。 当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。

  unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到:在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息,直到父进程通过wait/waitpid来取时才释放。保存信息包括:

  • 进程号the process ID
  • 退出状态the termination status of the process
  • 运行时间the amount of CPU time taken by the process等。
  1. 孤儿进程

  一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

  1. 僵尸进程

  一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用waitwaitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。僵尸进程是一个进程必然会经过的过程:这是每个子进程在结束时都要经过的阶段。

  如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是Z。如果父进程能及时处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。

  如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。

  • 危害

  如果进程不调用wait/waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程。

  • 外部消灭

  通过kill发送SIGTERM或者SIGKILL信号消灭产生僵尸进程的进程,它产生的僵死进程就变成了孤儿进程,这些孤儿进程会被init进程接管,init进程会wait()这些孤儿进程,释放它们占用的系统进程表中的资源

  • 内部解决

  1)子进程退出时向父进程发送SIGCHILD信号,父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程。
  2)fork两次,原理是将子进程成为孤儿进程,从而其的父进程变为init进程,通过init进程可以处理僵尸进程。

进程与线程的区别

  1. 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。
  2. 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。资源分配给进程,同一进程的所有线程共享该进程的大部分系统资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量,线程只拥有一些在运行中必不可少的私有属性,比如tcb,线程Id,栈、寄存器。
  3. 进程是资源分配的最小单位,线程是CPU调度的最小单位
  4. 系统开销: 由于在创建或撤消进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等。因此,操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。类似地,在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只须保存和设置少量寄存器的内容,并不涉及存储器管理方面的操作。可见,进程切换的开销也远大于线程切换的开销。
  5. 通信:由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信,但此时需要进程同步和互斥手段的辅助,以保证数据的一致性。在有的系统中,线程的切换、同步和通信都无须操作系统内核的干预。
  6. 进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂。
  7. 多进程间拥有各自独立的运行地址空间,进程间不会相互影响,程序可靠性强 ;线程间会相互影响,一个线程意外终止会导致同一个进程的其他线程也终止,程序可靠性弱。
  8. 进程适应于多核、多机分布;线程适用于多核。
  9. 进程切换需要分两步:切换页目录、刷新TLB以使用新的地址空间;切换内核栈和硬件上下文(寄存器);而同一进程的线程间逻辑地址空间是一样的,不需要切换页目录、刷新TLB

线程间通信

  线程间通信的方式有:

  1. 临界区:通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问;
  2. 互斥量Synchronized/Lock:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。
  3. 信号量Semphare:为控制具有有限数量的用户资源而设计的,它允许多个线程在同一时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目。
  4. 事件(信号)Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。

线程间的同步方式,最好说出具体的系统调用

  1. 信号量

  信号量是一种特殊的变量,可用于线程同步。它只取自然数值,并且只支持两种操作:

  • P(SV):如果信号量SV大于0,将它减一;如果SV值为0,则挂起该线程。
  • V(SV):如果有其他进程因为等待SV而挂起,则唤醒,然后将SV+1;否则直接将SV+1

  其系统调用为:

  • sem_wait(sem_t *sem):以原子操作的方式将信号量减1,如果信号量值为0,则sem_wait将被阻塞,直到这个信号量具有非0值。
  • sem_post(sem_t *sem):以原子操作将信号量值+1。当信号量大于0时,其他正在调用sem_wait等待信号量的线程将被唤醒。
  1. 互斥量

  互斥量又称互斥锁,主要用于线程互斥,不能保证按序访问,可以和条件锁一起实现同步。当进入临界区时,需要获得互斥锁并且加锁;当离开临界区时,需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程。其主要的系统调用如下:

  • pthread_mutex_init:初始化互斥锁。
  • pthread_mutex_destroy:销毁互斥锁。
  • pthread_mutex_lock:以原子操作的方式给一个互斥锁加锁,如果目标互斥锁已经被上锁,
  • pthread_mutex_lock:调用将阻塞,直到该互斥锁的占有者将其解锁。
  • pthread_mutex_unlock:以一个原子操作的方式给一个互斥锁解锁。
  1. 条件变量

  条件变量,又称条件锁,用于在线程之间同步共享数据的值。条件变量提供一种线程间通信机制:当某个共享数据达到某个值时,唤醒等待这个共享数据的一个/多个线程。即,当某个共享变量等于某个值时,调用signal/broadcast。此时操作共享变量时需要加锁。其主要的系统调用如下:

  • pthread_cond_init:初始化条件变量
  • pthread_cond_destroy:销毁条件变量
  • pthread_cond_signal:唤醒一个等待目标条件变量的线程。哪个线程被唤醒取决于调度策略和优先级。
  • pthread_cond_wait:等待目标条件变量。需要一个加锁的互斥锁确保操作的原子性。该函数中在进入wait状态前首先进行解锁,然后接收到信号后会再加锁,保证该线程对共享资源正确访问。

常用线程模型

  1. Future模型

  该模型通常在使用的时候需要结合Callable接口配合使用。

  Future是把结果放在将来获取,当前主线程并不急于获取处理结果。允许子线程先进行处理一段时间,处理结束之后就把结果保存下来,当主线程需要使用的时候再向子线程索取。

  Callable是类似于Runnable的接口,其中call方法类似于run方法,所不同的是run方法不能抛出受检异常没有返回值,而call方法则可以抛出受检异常并可设置返回值。两者的方法体都是线程执行体。

  1. fork&join模型

  该模型包含递归思想和回溯思想,递归用来拆分任务,回溯用合并结果。可以用来处理一些可以进行拆分的大任务。其主要是把一个大任务逐级拆分为多个子任务,然后分别在子线程中执行,当每个子线程执行结束之后逐级回溯,返回结果进行汇总合并,最终得出想要的结果。

  这里模拟一个摘苹果的场景:有100棵苹果树,每棵苹果树有10个苹果,现在要把他们摘下来。为了节约时间,规定每个线程最多只能摘10棵苹树以便于节约时间。各个线程摘完之后汇总计算总苹果树。

  1. actor模型

  actor模型属于一种基于消息传递机制并行任务处理思想,它以消息的形式来进行线程间数据传输,避免了全局变量的使用,进而避免了数据同步错误的隐患。actor在接受到消息之后可以自己进行处理,也可以继续传递(分发)给其它actor进行处理。在使用actor模型的时候需要使用第三方Akka提供的框架。

4、生产者消费者模型

  生产者消费者模型都比较熟悉,其核心是使用一个缓存来保存任务。开启一个/多个线程来生产任务,然后再开启一个/多个来从缓存中取出任务进行处理。这样的好处是任务的生成和处理分隔开,生产者不需要处理任务,只负责向生成任务然后保存到缓存。而消费者只需要从缓存中取出任务进行处理。使用的时候可以根据任务的生成情况和处理情况开启不同的线程来处理。比如,生成的任务速度较快,那么就可以灵活的多开启几个消费者线程进行处理,这样就可以避免任务的处理响应缓慢的问题。

5、master-worker模型

  master-worker模型类似于任务分发策略,开启一个master线程接收任务,然后在master中根据任务的具体情况进行分发给其它worker子线程,然后由子线程处理任务。如需返回结果,则worker处理结束之后把处理结果返回给master

操作系统原理(三)进程管理、线程、并发和并行相关推荐

  1. 操作系统原理:进程与线程、进程生命周期、线程的类型

    一.进程定义 进程可以看成程序的执行过程,可以展示在当前时刻的执行状态.它是程序在一个数据集合上的一次动态执行的过程.这个数据集合通常包含存放可执行代码的代码段,存放初始化全局变量和初始化静态局部变量 ...

  2. 5 操作系统第二章 进程管理 线程介绍

    文章目录 1 什么是线程 1.1 为什么要引入线程: 1.2 线程定义 1.3 线程与进程的比较 1.4 线程的属性 1.5 线程实现方式 1.5.1 用户级线程(ULT) 1.5.2 内核级线程(K ...

  3. 计算机工作原理及进程和线程的区别

    课前导读: 本篇是关于计算机工作原理和多线程编程的解读,主要包括计算机发展背景.图灵大佬生平.冯诺依曼体系结构.cpu与Gpu.电路门.CPU具体的特点.编程语言.Java的前世今生.操作系统.进程的 ...

  4. 笔记篇:操作系统第二章 进程管理

    笔记篇:操作系统第二章 进程管理 目录 笔记篇:操作系统第二章 进程管理 2.1 进程的基本概念 2.1.1 程序的顺序执行及其特征 2.1.2 前驱图 2.1.3 程序的并发执行及其特征 2.1.4 ...

  5. (王道408考研操作系统)第二章进程管理-第三节8:经典同步问题之吸烟者问题

    本文接: (王道408考研操作系统)第二章进程管理-第三节6:经典同步问题之生产者与消费者问题 ((王道408考研操作系统)第二章进程管理-第三节7:经典同步问题之多生产者与多消费者问题 文章目录 一 ...

  6. 操作系统原理,进程的基本状态,运行态,就绪态,等待态与转换模型,进程的其他状态,创建,终止,挂起与转换模型,Linux进程状态模型示例

    操作系统原理,进程的基本状态,运行态,就绪态,等待态与转换模型,进程的其他状态,创建,终止,挂起与转换模型,Linux进程状态模型示例 一.进程的三种基本状态: 运行态,就绪态,等待态 1.运行态: ...

  7. (王道408考研操作系统)第二章进程管理-第三节10:经典同步问题之哲学家进餐问题

    本文接: (王道408考研操作系统)第二章进程管理-第三节6:经典同步问题之生产者与消费者问题 ((王道408考研操作系统)第二章进程管理-第三节7:经典同步问题之多生产者与多消费者问题 (王道408 ...

  8. (王道408考研操作系统)第二章进程管理-第三节7:经典同步问题之多生产者与多消费者问题

    注意:生产者与消费者问题Linux系统编程专栏有案例讲解 Linux系统编程39:多线程之基于阻塞队列生产者与消费者模型 Linux系统编程40:多线程之基于环形队列的生产者与消费者模型 本文接:(王 ...

  9. 操作系统第三次实验——线程基础总结

    操作系统第三次实验--线程基础总结 文章目录 操作系统第三次实验--线程基础总结 查看CPU核数 创建简单线程 ps -eLF 查看线程 向线程中传递参数 传很多个参数 创建两个线程实现相关操作 查看 ...

  10. 线程并发和并行_并发性,并行性和圣诞老人​​的许多线程

    线程并发和并行 Consider the following: Santa brings toys to all the good girls and boys. 考虑以下几点:圣诞老人为所有好的男孩 ...

最新文章

  1. 【蓝桥java】递归基础之车辆进出栈
  2. 牛津、剑桥、OpenAI 等多家机构发布重磅报告,论述恶意人工智能的「罪与罚」
  3. clion 配置,操作,技巧
  4. const_cast
  5. canvas入门实战--邀请卡生成与下载
  6. CSS基本知识之盒子模型
  7. GridControl 设置自带选中复选框及设置该列列头名称
  8. Ubuntu 14.04 更换阿里云源
  9. delphi glsence 教程
  10. BitComet(比特彗星) BT磁力链下载推荐
  11. unity安装后续添加模块
  12. windows远程控制服务器
  13. 3d胆码计算机方法,3D选胆码方法公式汇总(近88期数据)
  14. Navicat Premium 导入SQL文件
  15. 暗色调Xshell配色方案
  16. 第二十一章 异步编程
  17. VC实现二维码(qrcode)编码源码
  18. android实现气泡聊天
  19. 电气simulink常用模块_「西门子1200PLC教程」2.CPU家族及模块
  20. 1146 -table 'performance_schema.session_variables' donesn't exist解决方案

热门文章

  1. Dotfuscator Professional Edition 4.1
  2. matlab里的deploy,MATLAB deploytool simulink未定义函数'load_system'
  3. 游戏开发之C++对C的扩展(C++基础)
  4. MySQL 第六次练习(索引)
  5. 从Spring中的@Transactional注解说起
  6. Django 查询表的几种方式
  7. VMware CTO: 2009年虚拟化十大预测
  8. AtCoder Grand Contest 018 A
  9. hdu - 5033 - Building(单调栈)
  10. codeforces水题100道 第二十五题 Codeforces Round #197 A. Helpful Maths (Div. 2) (strings)