目录
linux多进程
进程就是正在内存中运行中的程序,Linux下一个进程在内存里有三部分的数据,就是“代码段”、”堆栈段”和”数据段”。”代码段”,就是存放了程序代码。“堆栈段”存放的就是程序的返回地址、程序的参数以及程序的局部变量。而“数据段”则存放程序的全局变量,常数以及动态数据分配的数据空间(比如用new函数分配的空间)。
系统如果同时运行多个相同的程序,它们的“代码段”是相同的,“堆栈段”和“数据段”是不同的(相同的程序,处理的数据不同)。
ps(process status)命令用于显示当前进程的状态, grep 命令用于查找文件里符合条件的字符串。“ps”是在Linux中是查看进程的命令,“-e”参数代表显示所有进程,“-f”参数代表全格式。
ps -ef |grep redis 查看系统全部的进程,然后从结果集中过滤出包含“redis”单词的记录。
1、子进程创建
fork()函数用于产生一个新的进程,函数返回值pid_t是一个整数,在父进程中,返回值是子进程编号,在子进程中,返回值是0。
那么调用这个fork函数时发生了什么呢?fork函数创建了一个新的进程,新进程(子进程)与原有的进程(父进程)一模一样。**子进程和父进程使用相同的代码段;子进程拷贝了父进程的堆栈段和数据段。**子进程一旦开始运行,它复制了父进程的一切数据,然后各自运行,相互之间没有影响。fork函数对返回值做了特别的处理,调用fork函数之后,在子程序中fork的返回值是0,在父进程中fork的返回是子进程的编号,可以通过fork的返回值来区分父进程和子进程,然后再执行不同的代码。
子进程拷贝了父进程的堆栈段和数据段,也就是说,在父进程中定义的变量子进程中会复制一个副本,fork之后,子进程对变量的操作不会影响父进程,父进程对变量的操作也不会影响子进程。子进程会拷贝父进程的所有资源,变量。但是子进程从父进程拷贝下的所有资源会放到一个新的地址中。父子间共享的内存空间只有代码段。如果,子进程和父进程对变量只读,也就是说变量不会被改变,这时候,变量表现为共享的,此时物理空间只有一份。如果说父进程或者子进程需要改变变量,那么进程将会对物理内存进行复制,这个时候变量是独立的,也就是说,物理内存中存在两份空间。2-4-8进行增长,说明存在两份不同的空间—才能进行这样的复制。
2、通过将服务端改为多进程实现并发连接
思路:在每次accept到一个客户端的连接后,生成一个子进程,让子进程负责和这个客户端通信,父进程继续accept客户端的连接。因此socket的服务端在监听新客户端的同时,还可以与多个客户端进行通信。
在上一节的程序中修改服务端的主函数。在主进程接受(Accept)请求之后,创建子进程(复制代码)执行交互,父进程continue到while循环头部继续进行Accept。但是注意存在一个问题,由于子进程复制了整个代码段,用于监听的socket也会被复制了一份,对子进程来说,只需要与客户端通信,不需要监听客户端的连接,所以子进程关闭监听的socket。同理对父进程来说,只负责监听客户端的连接,不需要与客户端通信。
while (1)
{
if (TcpServer.Accept() == false) continue;
// 父进程(fork()函数返回值是一个整数,在子进程中返回的是0)回到while,继续Accept
if (fork()>0) { TcpServer.CloseClient(); continue; }
// 子进程负责与客户端进行通信,直到客户端断开连接。
TcpServer.CloseListen();
// 以下实现数据的收(接受客户端的数据)发(发送已收到数据的响应)
...
}
因此需要在类中增加两个成员函数。但是在设计模式中不推荐直接增加改动(因为直接修改,其他的也代码需要重新编译和部署),可以使用不同的设计模式进行代码修改,具体的办法还在学习中…
因此,目前采用的方法是:直接在类中添加成员函数。
void CloseClient(); // 关闭客户端的socket
void CloseListen(); // 关闭用于监听的socket
3、实验测试
首先将上一节的代码复制到新文件夹socket3中,然后vim修改代码。
cp -rp socket2 socket3
1、测试是否能连接多个客户端
将client调整为每发送一个数据延时5秒,发送5个数据在30s完成。在这段时间内开启多个客户端连接服务器,通过进程查看可知多个客户端的连接状态。
只修改客户端代码,也只需要make修改过的代码。
开启多个客户端查看连接情况:使用 netstat 命令用于显示网络状态,-n或–numeric 直接使用IP地址,而不通过域名服务器;-a或–all 显示所有连线中的Socket。
拓展一:僵尸进程
服务器端存在僵尸进程
僵尸进程有标志。由于服务端是保持开启的(实际不应该一直开启,后面修改),如果Ctrl+c终止server后,父进程退出,僵尸进程随之消失。
僵尸进程产生的原因
一个子进程在调用return或exit(0)结束自己的生命的时候,其实它并没有真正的被销毁,而是留下一个僵尸进程。僵尸进程是子进程结束时,父进程又没有回收子进程占用的资源。
僵尸进程在消失之前会继续占用系统资源。如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程。如果父进程先退出,子进程被系统接管,子进程退出后系统会回收其占用的相关资源,不会成为僵尸进程。
如何解决僵尸进程
百度百科上:解决僵尸进程的方法
1、父进程通过wait和waitpid等函数等待子进程结束,这会导致父进程挂起。在并发的服务程序中这是不可能的,因为父进程要做其它的事,例如等待客户端的新连接,不可能去等待子进程的退出信号,这个方法不太可取。
2、 如果父进程很忙,那么可以用signal函数为SIGCHLD安装handler,因为子进程结束后, 父进程会收到该信号,可以在handler中调用wait回收。
3、 如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCHLD,SIG_IGN)
通知内核,自己对子进程的结束不感兴趣,那么子进程结束后,内核会回收, 并不再给父进程发送信号。
4、 还有一些技巧,就是fork两次,父进程fork一个子进程,然后继续工作,子进程fork一 个孙进程后退出,那么孙进程被init接管,孙进程结束后,init会回收。不过子进程的回收 还要自己做。
在这里使用:父进程直接忽略子进程的退出信号,在主程序中启用以下代码:
signal(SIGCHLD,SIG_IGN); // 忽略子进程退出的信号,避免产生僵尸进程
拓展二:C10K问题
最初的服务器是基于进程/线程模型。新到来一个TCP连接,就需要分配一个进程。假如有C10K,就需要创建1W个进程,可想而知单机是无法承受的。当创建的进程或线程多了,数据拷贝频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞,进程/线程上下文切换消耗大, 导致操作系统崩溃,这就是C10K问题的本质。可见, 解决C10K问题的关键就是尽可能减少这些CPU资源消耗。
如何解决:每个进程/线程同时处理 多个连接(I/O多路复用)
epoll 通过两个方面,很好解决了 select/poll 的问题。
第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合(O(n)时间复杂度),只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
epoll 内核源码中,epoll_wait 实现的内核代码中调用了 __put_user
函数,这个函数就是将数据从内核拷贝到用户空间。
解决方法总结
最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。
1、使用多进程/线程模型
比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。
2、 I/O 的多路复用
为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是: select、poll、epoll。
2.1、select 和 poll
select 和 poll 并没有本质区别,它们内部都是使用**「线性结构」**来存储进程关注的 Socket 集合。在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。
很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。
2.2、epoll
1、通过两个方面解决了 select/poll 的问题。epoll 在内核里使用**「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。
2、epoll 使用事件驱动的机制**,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。
边缘触发和水平触发
使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,**服务器端只会从 epoll_wait 中苏醒一次,**即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,**服务器端不断地从 epoll_wait 中苏醒,**直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文切换。
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以进行设置。
拓展三:进程间通信
1、管道
| 管道,ps -ef 的输出作为 grep redis 的输入,管道传输数据是单向的。 | 匿名管道。命名管道 FIFO ,数据先进先出传输。mkfifo 命令来创建并加上管道名。Linux一切皆文件,使用ls之后发现管道文件类型为p。
echo “hello” > myPipe //将数据写入管道
cat < myPipe //读取管道里的数据
管道这种通信⽅式效率低,不适合进程间频繁地交换数据。
匿名管道的创建,需要通过下面这个系统调用:int pipe(int fd[2])。表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0]
,另一个是管道的写入端描述符 fd[1]
。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
我们可以使用 fork
创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0]
与 fd[1]
」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。
管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入,也都可以读出。那么,为了避免这种情况,通常的做法是:
- 父进程关闭读取的 fd[0],只保留写入的 fd[1];
- 子进程关闭写入的 fd[1],只保留读取的 fd[0];
对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。
另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。
2、消息队列
消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。
缺点:通信不及时,附件也有大小限制,这同样也是消息队列通信不足的点。
消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销。
3、共享内存
消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。
现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
**共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。**这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
4、信号量
信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。
接下来,举个例子,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 1
。可以发现,信号初始化为 1
,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。信号量来实现多进程同步的方式,我们可以初始化信号量为 0
,前V后p(前操作之后V,后操作之前p)。
5、信号
上面说的进程间通信,都是常规状态下的工作模式。**对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。**通过 kill -l
命令,查看所有的信号。Ctrl+C 产生 SIGINT
信号,表示终止该进程;
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,
6、Socket
前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。
实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。
创建 socket 的系统调用:
int socket(int domain, int type, int protocal)
参考1:C语言技术网
https://freecplus.net/8bd691add361411d84745282afa7e4fe.html
参考2:小林coding
https://blog.csdn.net/qq_34827674/article/details/115619261