文章目录
- 计算机内存分配
- 进程与子进程
- 流
- IO模型
- 非阻塞IO
- IO多路复用
- 网络IO模型
- 简单的socket
- 并发的socket
计算机内存分配
一个32位,4G内存的计算机,内存使用分为两部分:
- 操作系统内核空间;
- 应用程序的用户空间
- 使用的操作系统不同,分配方式不同;
进程与子进程
-
进程
是操作系统中资源管理的最小单位,它是将静态程序加载到内存中的一次动态的执行,包括进程创建、进程调度、进程销毁; -
每个进程有自己独有的内存(在用户空间内),进程的私有内存是相互独立的,且进程间无法直接通信;
-
不同进程间通信,可以采用队列、管道、信号、共享内存(内核空间内)等方式;
-
每个进程中可以创建一个或者多个线程,多个线程共享当前进程的部分内存资源,如代码、全局变量等;
-
进程的内存分布
-
比如上图中的shell脚本,运行时,shell是父进程,python3开启子进程,父进程会等待子进程退出;当关闭shell进程(父进程)时,python3子进程由init进程接管。
-
父进程中,当以fork()系统调用创建子进程时,子进程执行exit()系统调用退出后,内核中仍然存有子进程的信息,如pid, exit code, run time等,这些信息要等父进程通过wait/waitpid来回收,若一直未回收,则退出的子进程成为僵尸进程,一直占用系统资源。
-
僵持进程无法通过kill关闭,随着数量增多,系统资源耗尽,导致系统瘫痪。
流
- 可以进行IO(input输入、output输出)操作的内核对象;
- 如文件、管道、socket…
- 流的入口是fd (file descriptor);
IO模型
-
阻塞IO, 一直等待,不占用资源;无法同时处理多个任务;
用户进程发起读的系统调用,当内核中socket fd未就绪时,一直阻塞等待;
socket fd 就绪时,将内核中的socket数据拷贝到用户空间(拷贝期间阻塞等待);
accept()阻塞
-
非阻塞IO, 忙轮询,占用CPU;
应用程序不断轮询内核,对应的socket fd是否就绪,未就绪则返回(非阻塞);
若已就绪,则拷贝内核中socket的数据到用户空间(阻塞)。
accept()不阻塞
-
IO多路复用,多个IO复用一个进程/线程,既可以阻塞等待不占用资源,又可以同时并发处理多个任务;
- linux 支持select, poll, epoll
- 应用程序通过系统调用让内核同时监控多个socket fd,一旦有网络事件发生,内核就遍历找到对应的socket,将其标记为可读,然后将所有的socket fd返回给应用程序;
- 应用程序遍历所有的fd,找到就绪的fd,通过系统调用复制对应socket的数据到用户空间;
-
异步IO;
-
应用程序发起异步read操作后,立即返回;
-
内核中的fd就绪,复制数据完成,触发信号通知应用程序;
-
全程无阻塞;
-
信号驱动IO
-
首先注册信号处理函数;
-
检查内核socket fd 是否就绪,未就绪直接返回;
-
已就绪,则内核发送信号给应用程序,触发信号处理函数;
-
信号处理函数,发起系统调用,从内核空间拷贝socket数据到用户空间(阻塞);
非阻塞IO
- 忙轮询,占用CPU;
- 性能不如阻塞IO;
- 代码流程,不停地 遍历所有的fd,查看是否就绪;
IO多路复用
多个IO复用一个进程/线程,既可以阻塞等待不占用资源,又可以同时并发处理多个任务;
-
select
- 最大连接数默认1024;
两次拷贝
,先将所有的fd 从用户空间拷贝到内核空间,由内核监控是否有fd就绪(可读或可写),也就是有网络事件发生;一旦有fd就绪,则遍历所有的fd集合,找到对应的fd并将其 标记为就绪态(可读、可写),然后将所有的fd(fd集合)从内核空间拷贝到用户空间,用户进程内遍历所有的fd,找出就绪的从进行读写;两次遍历
;- 并发量大时,性能指数式下降;
- 代码流程:
-
poll,与select 没有本质的区别,只是连接数比select多;
- select 使用固定长度的 BitMap表示文件描述符集合,而poll 使用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,还会受到系统文件描述符限制。
- select/poll 都是使用线性结构存储进程的 socket 集合,都需要
遍历
文件描述符集合来找到可读或可写的 socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合。
-
epoll,高性能的IO多路复用(仅linux支持)
- 连接数更大,上限为进程的最大连接数;查看最大连接数cat /proc/sys/fs/file-max
- 内核中采用红黑树结构,可高效地增删查(O(logn)),仅返回就绪的fd;
- 将就绪的fd拷贝给用户进程,避免无用的遍历;
- 适合并发量大的场景,可以解决C10K问题(单台服务器并发1w),
- 操作流程,使用epoll_create 创建内核epoll对象;epoll_ctl将要监控的socket加入红黑树,内核检测到有网络事件发生,则将对应的socket 连接放入一个就绪链表中,并复制给用户空间(epoll_wait返回);
- epoll支持水平触发和边缘触发,边缘触发效率更高;select/poll仅仅支持水平触发,即内核socket缓冲区有数据就绪,在数据没有被进程读取完之前,会多次通知进程来读取;而边缘触发则仅仅通知一次,需要进程一次性读取所有的数据。例如,当快递放入快递站点时,管理员可能给你打多个电话催促你取快递,这种通知多次的方式就是水平触发;而当快递放入快递柜时,就只给你发送一次短信,仅仅通知一次,这种仅仅通知一次的方式就是边缘触发。
- IO多路复用中有socket 就绪,并不一定可读、可写,此时为避免进程阻塞,需要结合非阻塞IO一起使用。
网络IO模型
- 基于socket网络通信
- 客户端与服务端建立连接的过程
- 客户端的socket对象调用connect((ip, port));
- 服务器的网卡接收请求,并转发给OS ,实现TCP三次握手;同时在操作系统内核中维护两个队列,TCP半连接队列 & TCP全连接队列;半连接队列表示未完成三次握手,全连接队列表示完成三次握手,已完成socket连接;
- 内核从TCP全连接队列取出当前socket连接,存储到内核文件列表中,同时将其fd返回用户空间,存入进程数组;
- 应用程序中就可以拿着这个已连接的socket进行读写;
- 读/写时 就对应阻塞IO、非阻塞IO、IO多路复用、异步IO、信号驱动的IO的情况;
下面以python3语言为例演示socket的使用。
简单的socket
同时只能处理一个客户端的请求。
server:
import socket
import time
import sys
import signal # 注册信号的处理函数
def handler(signum: int, frame):
"""
接收到SIGINT信号时,打印一句话,并退出进程
"""
print("received signal:", signum)
sys.exit(0)
# 注册信号的处理函数
signal.signal(signal.SIGINT, handler) # 使用 Ctrl + C 发送SIGINT信号
# 网络层使用Ipv4
# 传输层使用TCP
sock_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 默认为阻塞的socket
# 绑定ip
sock_server.bind(("localhost", 8000))
# 最多监听1000个连接
sock_server.listen(1000) # 监听的socket
while True:
# TCP三次握手完成,接收socket连接
conn, addr = sock_server.accept() # accept阻塞 等待 socket连接就绪
# 处理当前的socket连接,conn是已连接的socket
print("conn:", conn, addr)
# 读 IO操作
data = conn.recv(1024) # 阻塞等待 内核中socket就绪,并拷贝数据到用户空间
print("received data:", data.decode())
# 写 IO操作,拷贝到内核空间,写入socket缓冲区
conn.send(b"hello, i am server. I have got your data.")
# 在当前socket连接 的请求处理完之前,服务端不会接收下一个客户端的socket连接
time.sleep(20)
client:
import socket
import time
# 创建客户端
sock_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 建立连接
sock_client.connect(("localhost", 8000))
# 发送数据
sock_client.send(b"hello, i am jack")
print("发送数据完成.")
# 接收数据
data = sock_client.recv(1024) # 接收 1024 bytes # 阻塞 内核fd未就绪->就绪 内核socket数据复制到用户空间
print("received data:", data.decode())
并发的socket
同时可以处理多个客户端的请求。
- 阻塞+多进程
- 随着请求数量的增多,子进程越来越多,(fork创建子进程复制父进程所有的资源)占用的系统资源越来越多,并发量大时会影响系统的性能,甚至导致系统崩溃;
- 多进程上下文的切换包括用户空间、内核空间,消耗系统性能;
- 所以并发量特别大时,多进程不是理想的方案。
# server.py
import socket
import os
import time
import sys
import signal # 注册信号的处理函数
import multiprocessing
def handler(signum: int, frame):
"""
接收到SIGINT信号时,打印一句话,并退出进程
"""
print("received signal:", signum)
sys.exit(0)
# 注册信号的处理函数
signal.signal(signal.SIGINT, handler) # 使用 Ctrl + C 发送SIGINT信号
# 处理请求
def handle_request(conn, addr):
print("subprocess:", os.getpid())
print("conn:", conn, addr)
flag = False
while not flag:
# 接收数据
data = conn.recv(1024) # recv from kernel
print("received data:", data.decode())
# 发送数据
conn.send(b"I am server. I have got your data.")
# 检测客户端的断开
data = conn.recv(1024)
print("客户端断开:", data.decode())
if not data:
conn.close()
print("客户端已断开.")
flag = True
print(f"{os.getpid()}子进程退出.")
if __name__ == "__main__":
# 网络层使用Ipv4
# 传输层使用TCP
sock_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 默认为阻塞的socket
# 绑定ip
sock_server.bind(("localhost", 8000))
# 最多监听1000个连接
sock_server.listen(1000) # 监听的socket
while True:
# TCP三次握手完成,接收socket连接
conn, addr = sock_server.accept() # accept阻塞 等待 socket连接就绪
# 父进程 阻塞等待连接
print("创建子进程.")
# 子进程处理 请求
sub_process = multiprocessing.Process(target=handle_request, args=(conn, addr))
sub_process.daemon = True
sub_process.start()
# client.py
import socket
import time
# 创建客户端
sock_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 建立连接
sock_client.connect(("localhost", 8000))
# 发送数据
sock_client.send(b"hello, i am jack")
print("发送数据完成.")
# 接收数据
data = sock_client.recv(1024) # 接收 1024 bytes
print("received data:", data.decode())
sock_client.close() # 客户端断开连接,会发送空数据到服务端
- 阻塞+多线程
- 线程是轻量级进程,同一个进程的多个线程可以共享当前进程的部分资源(代码、全局变量等),避免了过多的资源消耗;
- 多线程的上下文切换,虽比多进程轻量,但大量的线程来回切换,也会给系统造成不小的开销;
- 多线程需要考虑线程安全问题,另外每个线程也有自己的栈空间,也消耗内存;大量的线程必然会消耗大量的栈空间;
- 所以对于特别大的并发量时,多线程也不是理想的方案。
在这里插入代码片
- IO多路复用