天天看点

socket网络编程(三)多进程通信

目录

      • linux多进程
        • 1、子进程创建
        • 2、通过将服务端改为多进程实现并发连接
        • 3、实验测试
      • 拓展一:僵尸进程
        • 如何解决僵尸进程
      • 拓展二:C10K问题
        • 如何解决:每个进程/线程同时处理 多个连接(I/O多路复用)
        • 解决方法总结
      • 拓展三:进程间通信
        • 1、管道
        • 2、消息队列
        • 3、共享内存
        • 4、信号量
        • 5、信号
        • 6、Socket

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修改过的代码。

socket网络编程(三)多进程通信

开启多个客户端查看连接情况:使用 netstat 命令用于显示网络状态,-n或–numeric 直接使用IP地址,而不通过域名服务器;-a或–all 显示所有连线中的Socket。

socket网络编程(三)多进程通信

拓展一:僵尸进程

服务器端存在僵尸进程

僵尸进程有标志。由于服务端是保持开启的(实际不应该一直开启,后面修改),如果Ctrl+c终止server后,父进程退出,僵尸进程随之消失。

socket网络编程(三)多进程通信

僵尸进程产生的原因

一个子进程在调用return或exit(0)结束自己的生命的时候,其实它并没有真正的被销毁,而是留下一个僵尸进程。僵尸进程是子进程结束时,父进程又没有回收子进程占用的资源。

僵尸进程在消失之前会继续占用系统资源。如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程。如果父进程先退出,子进程被系统接管,子进程退出后系统会回收其占用的相关资源,不会成为僵尸进程。

如何解决僵尸进程

百度百科上:解决僵尸进程的方法

1、父进程通过wait和waitpid等函数等待子进程结束,这会导致父进程挂起。在并发的服务程序中这是不可能的,因为父进程要做其它的事,例如等待客户端的新连接,不可能去等待子进程的退出信号,这个方法不太可取。

2、 如果父进程很忙,那么可以用signal函数为SIGCHLD安装handler,因为子进程结束后, 父进程会收到该信号,可以在handler中调用wait回收。

3、 如果父进程不关心子进程什么时候结束,那么可以用

signal(SIGCHLD,SIG_IGN)

通知内核,自己对子进程的结束不感兴趣,那么子进程结束后,内核会回收, 并不再给父进程发送信号。

4、 还有一些技巧,就是fork两次,父进程fork一个子进程,然后继续工作,子进程fork一 个孙进程后退出,那么孙进程被init接管,孙进程结束后,init会回收。不过子进程的回收 还要自己做。

在这里使用:父进程直接忽略子进程的退出信号,在主程序中启用以下代码:

socket网络编程(三)多进程通信

拓展二: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网络编程(三)多进程通信

边缘触发和水平触发

使用边缘触发模式时,当被监控的 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

,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。信号量来实现多进程同步的方式,我们可以初始化信号量为

,前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