并发服务器

多进程服务器:通过创建多个进程提供服务(一个程序运行的过程中也会产生多个进程)

多路复用服务器:通过捆绑并同意管理I/O对象提供服务

多线程服务器:通过生成与客户端等量的线程提供服务

进程:占用内存空间的正在运行的程序

无论进程是怎么创建的,所有进程都会从操作系统分配到ID。此ID称为“进程ID(PID)”,其值为大于2的整数。1要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户进程无法得到ID值1 。

创建进程:

#include<unistd.h>

pid_t fork(void);//成功返回进程ID,失败返回-1

fork函数将创建调用的进程副本。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用fork函数的进程。另外,两个进程都将执行fork函数调用后的语句(准确说是在fork函数返回后)。但因为通过同一个进程、复制相同的内存空间,之后的程序流要根据fork函数的返回值加以区分。

父进程(原进程,即调用fork函数的主体):fork函数返回子进程ID

子进程(通过父进程调用fork函数复制出的进程):fork函数返回0

调用fork函数后,父子进程拥有完全独立的内存结构。

进程销毁和进程创建同等重要,如果没有销毁进程,它们将变成僵尸进程,占用系统中的重要资源。

终止fork函数产生的子进程:

1. 传递参数并调用exit函数

2. main函数中执行return语句并返回值。

将子进程变成僵尸进程的正是操作系统:exit函数传递的参数值和main函数的return语句返回的值都会传递给操作系统,而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。

僵尸进程应该何时销毁:向创建子进程的父进程传递子进程的exit参数值或return语句的返回值。操作系统不会主动把这些值传递给父进程,只有父进程主动发起请求(函数调用)时,操作系统才会传递该值。换句话说:如果父进程未主动要求获得子进程的结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。

僵尸进程的进程状态为Z+

销毁僵尸进程1:利用wait函数

#include<sys/wait.h>

pid_t wait(int* statloc);//成功返回终止的子进程ID,失败返回-1

调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit函数的参数值、main函数的return返回值)将保存到该函数的参数所指内存空间。注意:该函数参数指向的单元中还包括其他信息,需要通过下列宏进行分离:

1. WIFEXITED: 子进程正常终止时返回“真”

2. WEXITSTATUS: 返回子进程的返回值

调用wait函数时,如果没有已终止的子进程,那么程序将阻塞直到有子进程终止,因此需谨慎调用该函数。

销毁僵尸进程2:利用waitpid函数

#include<sys/wait.h>

pid_t waitpid(pid_t pid, int* statloc, int options);//成功返回终止的子进程ID(或0),失败返回-1

//pid:等待终止的目标子进程的ID,若传递-1,则与wait函数相同,可以等待任意子进程终止

//statloc:与wait函数的statloc参数具有相同含义

//options:传递头文件sys/wait.h中声明的常量WNOHANG , 即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数

即,调用waitpid函数时,程序不会阻塞

#include<signal.h>

void(*signal(int signo , void (*func)(int)))(int);//为了在产生信号时调用,返回之前注册的函数指针

函数名:signal

参数:int signo , void(* func)(int)

返回类型:参数类型为int,返回void型函数指针

调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指的函数。

SIGALRM:已到通过调用alarm函数(闹钟函数)注册的时间

SIGINT:输入CTRL+C

SIGCHLD:子进程终止

#include<unistd.h>

unsigned int alarm(unsigned int seconds);//返回0或以秒为单位的距SIGALRM信号发生所剩时间

如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生SIGALRM信号。若向该函数传递0,则之前对SIGALRM信号的预约将取消。如果通过该函数预约信号后未指定该信号对应的处理函数,则(通过调用signal函数)终止进程,不做任何处理。

发生信号时,将唤醒由于调用sleep函数而进入阻塞状态的进程。调用函数的主体的确是操作系统,但进程处于睡眠状态时无法调用函数。因此,产生信号时,为了调用信号处理器,将唤醒由于调用sleep函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到sleep函数中规定的时间也是如此。

sigaction函数可以完全代替signal函数,且更加稳定

#include<signal.h>

int sigaction(int signo , const struct sigaction* act , struct sigaction* oldact);//成功返回0,失败返回-1

//signo:与signal函数相同,传递信号信息

//act:对应于第一个参数的信息处理函数(信号处理器)信息

//oldact:通过此参数获取之前注册的信号处理函数指针,若不需要则传递0

struct sigaction

{

        void (*sa_handler)(int);//保存信号处理函数的指针值(地址值)

        sigset_t sa_mask;

        int sa_flags;

}

通过fork函数复制文件描述符,父进程将套接字的文件描述符复制给子进程。注意:只复制文件描述符,不复制套接字。因为套接字并非进程所有,套接字属于操作系统,进程拥有代表相应套接字的文件描述符。

一个套接字中存在2个文件描述符时,只有2个文件描述符都终止(销毁)后,才能销毁套接字。 

进程具有完全独立的内存结构。就连fork函数创建的子进程也不会与父进程共享内存空间。所以,进程间通信要求操作系统提供两个进程可以同时访问的内存空间。

通过管道完成进程间通信。注意:管道并非属于进程的资源,而是和套接字一样,属于操作系统(也就不是fork函数的复制对象)。

#include<unistd.h>

int pipe(int filedes[2]);成功返回0,失败返回-1

//filedes[0]:通过管道接收数据时使用的文件描述符,即管道出口

//filedes[1]:通过管道传输数据时使用的文件描述符,即管道入口

注意:fork函数复制的不是管道,而是用于管道I/O的文件描述符

多进程模型的缺点:

创建进程的过程会带来一定的开销

为了完成进程间数据交换,需要特殊的IPC(进程间通信)技术

每秒少则数十次,多则数千次的‘上下文切换’是创建进程时最大的开销

基于I/O复用的服务器端:

I/O复用服务器端的进程需要确认举手(收到数据)的套接字,并通过举手的套接字接收数据。

select函数可以将多个文件描述符集中到一起统一监视:

1. 是否存在套接字接收数据?

2. 无需阻塞传输数据的套接字有哪些?

3. 哪些套接字发生了异常?

监视项称为“事件”,发生监视项对应情况时,称“发生了事件”。

select函数的调用过程:

步骤一:设置文件描述符、指定监视范围、设置超时

步骤二:调用select函数

步骤三:查看调用结果

设置文件描述符:

fd_set变量中注册或更改值的操作都有下列宏完成:

FD_ZERO(fd_set* fdset) : 将fd_set变量的所有位初始化为0

FD_SET(int fd , fd_set* fdset) : 在参数fdset指向的变量中注册文件描述符fd的信息

FD_CLR(int fd , fd_set* fdset) : 从参数fdset指向的变量中清除文件描述符fd的信息

FD_ISSET(int fd , fd_set* fdset) : 若参数fdset指向的变量中包含文件描述符fd的信息,则返回“真”

设置检查(监视)范围及超时:

#include<sys/select.h>

#include<sys/time.h>

int select(int maxfd , fd_set* readset , fd_set* writeset , fd_set* exceptset ,

const struct timeval* timeout);//成功返回大于0的值,失败返回-1

maxfd:监视对象文件描述符数量

readset:将所有关注“是否存在待读取数据”的文件描述符注册到fd_set型变量,并传递其地址值

writeset:将所有关注“是否可传输无阻塞数据”的文件描述符注册到fd_set型变量,并传递其地址值

exceptset:将所有关注“是否发生异常”的文件描述符注册到fd_set型变量,并传递其地址值

timeout:调用select函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息

返回值:发生错误时返回-1,超时返回时返回0。因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。

文件描述符的监视范围与select函数的第一个参数有关,只需将最大的文件描述符值加1再传递到select函数即可,加1是因为文件描述符的值从0开始。

select函数的超时时间与select函数的最后一个参数有关。

struct timeval

{

       long tv_sec;                //seconds

       long tv_usec;             //microseconds

}

本来select函数只有在监视的文件描述符发生变化时才返回,如果未发生变化,就会进入阻塞状态。将秒传递给tv_sec,将微秒传递给tv_usec,然后将结构体的地址值传递到select函数的最后一个参数。此时,即使文件描述符中未发生变化,只要过了指定时间,也可以从函数中返回0。如果不想设置超时,则传递NULL参数。

select函数调用完成后,向其传递的fd_set变量中将发生变化,原来为1的所有位均变为0,但发生变化的文件描述符对应位除外,因此,可以认为值仍为1的位置上的文件描述符发生了变化。

注意:

1. 要将准备好的fd_set变量reads的内容复制到temps变量,因为调用select函数后,除发生变化的文件描述符对应位外,剩下的所有位将初始化为0 。因此为了记住初始值,必须经过这种复制过程。

2. 调用select函数后,结构体timeval的成员tv_sec和tv_usec的值将被替换为超时前剩余时间,因此,调用select函数前,每次都需要初始化timeval结构体变量。

epoll

epoll_create : 创建保存epoll文件描述符的空间

epoll_ctl : 向空间注册并注销文件描述符

epoll_wait : 与select函数类似,等待文件描述符发生变化

epoll方式下由操作系统负责保存监视对象文件描述符。因此,需要想操作系统请求创建保存文件描述符的空间,此时使用的函数就是epoll_create。

在epoll方式中,为了添加和删除监视对象文件描述符,通过epoll_ctl函数请求操作系统完成。

epoll中调用epoll_wait函数等待文件状态变化。

epoll方式中通过如下结构体epoll_event将发生变化的(发生事件的)文件描述符单独集中到一起:

struct epoll_event

{

        _ _uint32_t events;

        epoll_data_t data;

}

typedef union epoll_data

{

        void* ptr;

        int fd;

        _ _uint32_t u32;

        _ _uint64_t u64;

}epoll_data_t;

#include <sys/epoll.h>

int epoll_create(int size);        //成功返回epoll文件描述符,失败返回-1

size:epoll实例的大小(只是向操作系统提的建议。即,size并非用来决定epoll例程的大小,仅供操作系统参考。内核2.6.8之后完全忽略传入的size参数)。

调用epoll_create函数时创建的文件描述符保存空间称为“epoll例程”。

epoll_create函数创建的资源与套接字相同,也由操作系统管理。需要终止时,与其他文件描述符相同,也要调用close函数。

#include<sys/epoll.h>

int epoll_ctl(int epfd , int op , int fd , struct epoll_event* event);   //成功返回0,失败返回-1

epfd:用于注册监视对象的epoll例程的文件描述符

op:用于指定监视对象的添加、删除、或更改等操作

fd:需要注册的监视对象文件描述符

event:监视对象的事件类型

第二个参数:

EPOLL_CTL_ADD : 将文件描述符注册到epoll例程

EPOLL_CTL_DEL : 从epoll例程中删除文件描述符(同时向第四个参数传递NULL)

EPOLL_CTL_MOD : 更改注册的文件描述符的关注事件发生情况

第四个参数:

先来个例子:

struct epoll_event event;

......

event.events = EPOLLIN;        //发生需要读取数据的情况(事件)时

event.data.fd = sockfd;

epoll_ctl(epfd , EPOLL_CTL_ADD , sockfd , &event);

上述代码将sockfd注册到epoll例程epfd中,并在需要读取数据的情况下产生相应事件。

epoll_event的成员events可以保存的常量:

EPOLLIN :需要读取数据的情况

EPOLLOUT :输出缓冲为空,可以立即发送数据的情况

EPOLLPRI :收到OOB数据的情况

EPOLLRDHUP :断开连接或半关闭的情况,这在边缘触发方式下非常有用

EPOLLERR :发生错误的情况

EPOLLET :以边缘触发的方式得到事件通知

EPOLLONESHOT :发生一次事件后,相应文件描述符不再收到事件通知。因此需要向epoll_ctl函数的第二个参数传递EPOLL_CTL_MOD,再次设置事件。

可以通过位或运算同时传递多个上述参数。

#include<sys/epoll.h>

int epoll_wait(int epfd , struct epoll_event* events , int maxevents , int timeout);//成功返回发生事件的文件描述符,失败返回-1 。

epfd :表示事件发生监视范围的epoll例程的文件描述符

events :保存发生事件的文件描述符集合的结构体地址值(所指缓冲需要动态分配)

maxevents :第二个参数中可以保存的最大事件数

timeout :以1/1000秒为单位的等待时间,传递-1时,一直等待直到发生事件

该函数调用方式:

int event_cnt;

struct epoll_event* ep_events;

......

ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);//EPOLL_SIZE是宏常量

......

event_cnt = epoll_wait(epfd , ep_events , EPOLL_SIZE , -1);

......

调用函数后,返回发生事件的文件描述符数,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。

条件(水平)触发和边缘触发

条件(水平)触发方式中,只要输入缓冲有数据就会一直通知该事件。

边缘触发中输入缓冲收到数据时仅注册一次该事件。即使输入缓冲中还留有数据,也不会再进行注册。

边缘触发的服务器端实现中必知的两点:通过errno变量验证错误原因(需要引入error.h头文件)、为了完成非阻塞I/O,更改套接字特性。

1.水平触发的时机

  1. 对于读操作,只要缓冲内容不为空,LT模式返回读就绪。
  2. 对于写操作,只要缓冲区还不满,LT模式会返回写就绪。

当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。

2.边缘触发的时机

  • 对于读操作
  1. 当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
  2. 当有新数据到达时,即缓冲区中的待读数据变多的时候。
  3. 当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。
  • 对于写操作
  1. 当缓冲区由不可写变为可写时。
  2. 当有旧数据被发送走,即缓冲区中的内容变少的时候。
  3. 当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。

当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

将套接字改为非阻塞方式的方法:

#include<fcntl.h>

int fcntl(int filedes , int cmd , ...);   //成功返回cmd参数相关值,失败返回-1

filedes:属性更改目标的文件描述符

cmd:表示函数调用的目的

向第二个参数传递F_GETFL,可以获得第一个参数所指的文件描述符属性(int型)。如果传递F_SETFL , 可以更改文件描述符属性。若希望将文件(套接字)改为非阻塞模式,需要如下两条语句:

int flag = fcntl(fd , F_GETFL , 0);

fcntl(fd , F_SETFL , flag | O_NONBLOCK);

通过第一条语句获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞O_NONBLOCK标志。

注意:边沿触发一定要采用非阻塞read&write函数。

边缘触发优点:可以分离接收数据和处理数据的时间点

多线程服务器

线程相比于进程有如下优点:

1. 线程的创建和上下文切换比进程的创建和上下文切换更快

2. 线程间交换数据时无需特殊技术

每个进程的内存空间都由保存全局变量的“数据区”、向malloc等函数的动态分配提供空间的堆、函数运行时使用的栈构成。每个进程都拥有这种独立空间。

但如果以获得多个代码执行流为主要目的,则不应该像进程那样完全分离内存结构,而只需分离栈区域。通过这种方式可以获得如下优势:

1. 上下文切换时不需要切换数据区和堆

2. 可以利用数据区和堆交换数据

 也就是说,线程为了保持多条代码执行流而隔开了栈区域。多个线程将共享数据区和堆,为了保持这种结构,线程将在进程内创建并运行。

进程:在操作系统构成单独执行流的单位

线程:在进程构成单独执行流的单位

如果说进程在操作系统内部生成多个执行流,那么线程就在同一进程内部创建多条执行流。

线程具有单独的执行流,因此需要单独定义线程的main函数,还需要请求操作系统在单独的执行流中执行该函数,完成该功能的函数如下;

#include<pthread.h>

int pthread_create(pthread_t* restrict thread , const pthread_attr_t* restrict attr , void* (start_routine)(void*) , void* restrict arg);        //成功返回0,失败返回其他值

thread:保存新创建线程ID的变量地址值,线程与进程相同,也需要用于区分不同线程的ID。

attr:用于传递线程属性的参数,传递NULL时,创建默认属性的线程。

start_routine:相当于线程main函数的、在单独执行流中执行的函数地址值(函数指针)。

arg:通过第三个参数传递调用函数时包含传递参数信息的变量地址值。

当pthread_create函数调用,请求创建一个线程,在单独的执行流中执行第三个参数所指的函数,参数由第四个参数提供。

注意:main函数返回后整个进程都将被销毁(包括线程)。

利用下面的函数控制线程的执行流:

#include<pthread.h>

int pthread_join(pthread_t thread , void** status);        //成功返回0,失败返回其他值

thread:该参数值ID的线程终止后才会从该函数返回;

status:保存线程的main函数返回值的指针变量地址;

即,调用该函数的进程(或线程)将进入等待状态,直到第一个参数为ID的线程终止为止。而且可以得到线程的main函数返回值。

在Linux命令行中运行线程时,要添加-lpthread。eg:gcc mythread.c -o mythread -lpthread

Linux提供D_REENTRANT宏将非线程安全函数改为线程安全函数。

eg:gcc -D_REENTRANT mythread.c -o mythread -lpthread

临界区:函数内同时运行多个线程时引起问题的多条语句构成的代码块。临界区通常位于由线程运行的函数内部。

2条不同语句由不同线程同时执行时,也有可能构成临界区。前提是这2条语句访问同一内存空间。

线程同步:线程同步用于解决线程访问顺序引发的问题。

需要同步的情况可以从如下两方面考虑:

1. 同时访问同一内存空间时发生的情况

2. 需要指定访问同一内存空间的线程执行顺序的情况

互斥量:表示不允许多个线程同时访问。互斥量主要用于解决线程同步访问的问题。

#include<pthread.h>

int pthread_mutex_init(pthread_mute_t* mutex , const pthread_mutexattr_t* attr);

int pthread_mutex_destroy(pthread_mutex_t* mutex);

//成功返回0,失败返回其他值。

mutex:创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址值

attr:传递即将创建的互斥量属性,没有特别需要指定的属性时传递NULL

为了创建相当于锁系统的互斥量,需要声明如下pthread_mutex_t型变量:pthread_mutex_t mutex

如果第二个参数为NULL时,可以利用PTHREAD_MUTEX_INITIALIZER宏进行如下声明:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;(不推荐)

利用互斥量锁住和释放临界区时使用的函数:

#include<pthread.h>

int pthread_mutex_lock(pthread_mutex_t* mutex);

int pthread_mutex_unlock(pthread_mutex_t* mutex);

//成功时返回0,失败返回其他值

信号量:

#include<semaphore.h>

int sem_init(sem_t* sem , int pshared , unsigned int value);

int sem_destroy(sem_t* sem);

//成功返回0,失败返回其他值

sem:创建信号量时传递保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值。

pshared:传递其他值时,创建可由多个进程共享的信号量;传递0时,创建只允许1个进程内部使用的信号量。我们需要完成同一进程内的线程同步,故传递0

value:指定新创建的信号量初始值

信号量中相当于互斥量lock、unlock函数:

#include<semaphore.h>

int sem_post(sem_t* sem);

int sem_wait(sem_t* sem);

//成功返回0,失败返回其他值

sem:传递保存信号量读取值的变量地址值,传递给sem_post时信号量增1,传递给sem_wait时信号量减1

因为信号量的值不能小于0,因此,在信号量为0的情况下调用sem_wait函数时,调用函数的线程将进入阻塞状态。当然,此时如果有其他线程调用sem_post函数,信号量的值将变为1,原本阻塞的线程将该信号量重新减为0并跳出阻塞状态。

线程的销毁;

linux线程并不是在首次调用的线程main函数返回时自动销毁,所以用如下2种方法之一加以明确,否则由线程创建的内存空间将一直存在:

1. 调用pthread_join函数:调用该函数时,不仅会等待线程终止,还会引导线程销毁。但是该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。

2. 调用pthread_detach函数

#include<pthread.h>

int pthread_detach(pthread_t thread);//成功返回0,失败返回其他值

thread:终止的同时需要销毁的线程ID

调用上述函数不会引起线程终止或进入阻塞状态,可以通过该函数引导销毁线程创建的内存空间。调用该函数后,不能再针对相应线程调用pthread_join函数。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值