网络编程

socket简介

计算机网络五层模型由应用层,传输层(tcp udp),网络层(icmp ip),数据链路层(arp)和物理层构成,我们日常使用的网络都在应用层进行,操作系统给我们提供了屏蔽底层复杂封装报文的过程,在应用层和传输层之间抽象出了socket抽象层,该层向上为用户提供接口,向下实现了各层的报头封装功能,我们在进行网络编程时,只需调用socket中提供的接口即可。

socket语法

直接上代码示例,服务器端

import socket

sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM)	#实例化对象
														# 网络家庭中使用tcp格式,要换为文件或udp,分别修改为AF_UNIX和DGRAM即可
sock.bind(('ip',端口)) 	#绑定IP端口
sock.listen(n) # 监听端口,允许排队的个数为n
conn,addr=sock.accept() # 获取连接对象和地址
data=conn.recv(字节) # 接收文件数据

客户端

# 创建套接字过程相同
s.connect(('ip',端口))	# 连接服务器
s.send(msg).encode('utf-8')	# 发送数据

远程操作,将发送数据直接在服务器端本地运行:

ret=subprocess.Popen(cmd命令.decode(),shell=True) # 执行cmd命令
correct_msg=stdout.read()	# 获取正确信息
error_msg=stderr.read()	# 获取错误信息

udp协议下使用socket,udp无连接,使用socket(socket.AF_INET,socket.DGRAM)创建对象,发送先使用ip_port=('ip',端口)定义目的,再使用sendto(内容,目的)发送,接收使用recvfrom(字节)即可,接收来自任意客户端的数据。

粘包处理

每次的发送和接收都并不直接读取,而是通过一个输入输出缓冲区实现,我们使用recv(字节)接收数据时设定的字节就是缓冲区大小,如果一次发不完就会在下一次接收或发送命令中带着,两次请求在一次处理中出现,就是粘包。

上述问题我们可以通过第三方struct模块修正,该模块能将字符转换为固定长度,ret=struct.pack('类型',内容),反解返回元组使用unpack实现,可以先将数据的长度发送给对方,对方经过判断接收完整后再一并输出。

当然其他解决方案还很多,第一种是固定缓冲区大小,每次都读写固定长度的内容,该方法效率低,因为数据少时需要用空字符填补,增加了传输负担;
该问题最主要的是网络以流传输形式进行,无法分辨数据属于哪个数据报,所以我们可以通过设定特殊字符表示数据报结束的方式来实现分包,该方法不论接收效率还是开发效率都很高,是目前常用的办法。

python并发编程

进程

进程是运行着的程序,是操作系统资源调度的基本单位,其基本语法如下:

import multiprocessing
p=Process(target=函数名,args=(参数,)) # 获取进程对象,参数以元组形式传入,所以需要加入,
p.start() # 启动进程

也可以使用继承的方法创建进程,类中继承Process,实例化该对象时就生成进程对象,重写init方法需要首先调用父类的方法,即super.__init__(self),然后重写父类的run方法,该方法在调用时自动执行。这种创建类的方法针对较为复杂的进程,小型的进程直接使用Process(target=)即可。

进程间通信

通过队列实现,不同进程从队列中读写,从而实现进程的通信,使用示例如下:

from multiprocessing import Queue
q=Queue(长度) # 创建队列
q.put(内容) # 往队列里放数据
q.get() # 取数据
q.full() # 判断队列是否已满

队列先进先出,也可以通过LifoQueue设置先进后出队列,PirorQueue设置优先级队列,通过q.put(优先级,值)放入消息。
当队列满时不能再放,空了不能再取,否则会一直等待,所以当操作完成后应该往队列中传入结束信号,比如None如果获取到的结果为None则结束通信,但多个消费者状态下,会传入多个结束信号,此时可以使用q.join()方法,阻塞进程直到队列为空再继续执行,消费者每次从队列中取时应调用q.task_done()方法,告诉队列已经进行了一次get处理。
说实话,我觉得这都可以再集成起来,get时默认调用task_done方法不行吗?

进程间通信要通过内核作为中介,需要多次拷贝,效率低且易产生安全问题,由此产生了内存共享技术,给不同进程划分一片内存区域作为公共内存,不同进程都可以进行读写,加快访问速度。python示例代码如下:

from multiprocessing import Process,Manager
 manager=Manager() # 创建共享对象
 list=manager.list[] # 创建共享列表

即通过共享对象修饰后的数据都可以被不同的进程访问,Manager可以管理的共享数据类型有:Value、Array、dict、list、Lock、Semaphore和类的实例对象。

进程池

开启多进程效率很高,但资源占用也很大,笔者尝试过创建一千个进程时电脑基本就卡死了,进程池是用固定容量的进程执行多个任务的方法,该方法效率低,但资源利用率高,多进程在池中轮转执行,更多的是减少了开启多个进程时的代码量,示例代码如下:

import Pool
pool=Pool(长度) # 创建进程池对象
pool.map(函数,range(个数)) # 给进程池添加函数

进程池中的进程也可以控制同步异步执行,使用p.apply(函数,参数)时,进程池中的进程会依次运行,使用p.apply_async(函数,参数)时,进程池中的函数异步执行,执行完成后就释放。

回调函数

可以使用回调函数,即将函数名作为参数传递的方法,p.apply_async(函数1,参数,callback=函数2)让函数2处理函数1产生的数据。乍一看有点整不明白,这个1的数据是怎么传到函数2里的,经过一番查询,该异步函数详细代码应该是这样的:

def apply_ascyn(func, args, callback):
    result = func(*args) # 获取函数1的数据
    callback(result) # callback只是作为一个函数参数传递进来,专门处理函数1的数据

要说回调函数有什么用,其实是类似接口和封装的思想,使用一个参数作为补位,处理不同数据只需要对callback位置的函数进行重新定义即可,而不用再到处理位置进行修改,这一番查询也加深了我对计算机编程上的理解,先抽象,再具体,先捋清楚大概思路,要获取什么数据,在什么位置进行处理,随后再写具体的获取和处理方法,其实先前在软件工程的课中应该有学习,只是当时没注意,学习果然是回旋镖。

线程

进程的创建、撤销、切换都有较大的时空开销,需引入轻量级的进程,从而产生线程,进程是资源分配的最小单位,线程是cpu调度的最小单位。

python中的多线程是“伪”多线程,该语言在设计之初,设定在主循环中只有一个线程能够执行,由全局解释器锁GIL进行控制,其代码通过虚拟机解释执行。

具体使用示例如下:

from threading import Thread
t=Thread(target=函数,args=(参数,)) # 创建线程对象,参数为元组形式
t.start() # 开启线程
# 同样支持join方法,方法几乎与进程完全相同,也可使用类继承的方法创建线程

与进程对比

线程执行速度更快,可共享主进程内存,进程间无法互相访问内存

协程

不管是进程还是线程,都需要操作系统进行管理,其创建、切换和撤销都需要开销,内卷的程序员又想:能不能在线程的基础上再实现并发,故协程是从代码层面追求效率,实现并发执行的产物。该方法无法被操作系统感知,在线程内实现并发效果,最大效率利用cpu。代码层面有很多实现手段,首先介绍我们之前学过的yield关键字,该方法可以保存函数执行状态,待sendnext指令后再继续执行,可以通过该方法实现协程的切换。

此外还可以使用greelet模块,具体使用示例如下:

g1=greelet(函数名) # 实例化对象
g1.switch() # 切换到g1

遇见阻塞等方式用户可以手动切换执行,基于该模块产生了Gevent模块,该模块会识别常见的阻塞并自动切换,使用具体示例如下:

from gevent import monkey:monkey.patch_all() # 打补丁,便于识别所有阻塞,执行到io操作时自动切换
g1=gevent.spawn(函数名,参数) # 实例化对象
g2=gevent.spawn(函数名,参数) # 实例化对象
g1.join() # 开始执行,遇见阻塞自动切换协程
g2.join()

同步异步

主进程结束时子进程若仍在运行,则会死在内存,操作系统将无法进行管理,为了避免这种情况,可以使用join()方法阻塞进程,等待子进程结束后再向下执行,主进程等待子进程结束后回收资源。多进程下可以用该方法实现同步异步。
也可以设置守护进程p.daemon=True,开启守护进程后,主进程结束后会自动杀死守护进程,或者手动执行p.terminate()关闭进程,但该方法不会立即关闭进程,而是等进程结束后再回收资源,可以通过is_alive()查看是否存活。

也可以使用锁实现,多个进程使用同一份数据或资源时会引发数据安全或顺序混乱问题,使用示例如下:

lock=Lock() # 创建锁对象
lock.acquire() # 加锁
代码段
lock.release() # 解锁

该方法效率较低,需要用户自己加锁,如果一个进程需要两次加锁或解锁,会产生死锁,故该方法不常用。

死锁

不同进程要获取的资源无法释放时,出现相互等待而永远无法满足的现象为死锁。为解决该现象,允许同一线程多次请求同一资源,python提供了可重入锁RLock,内部由lock和计数器counter构成,记录acquire次数,使得其可被多次acquire,一个线程所有的acquire都被release时其他线程才能获得资源。具体使用方法为:rLock = threading.RLock() #RLock对象加锁解锁方法与lock完全相同。

但是啥时候一个线程要加多把锁呢?网上查了查也只有这个说法,没说具体环境,好像也用不着,就是怕程序员写错了吧。

同步异步是与多进程相伴相生的问题,本质是用户与操作系统的矛盾,程序以一种不可知的状态被操作系统调度,而操作系统本身的优化算法可能让程序不按用户的想法执行,因此被迫使用锁这种方法来牺牲效率换取程序运行准确,比如多个进程要对同一片内存进行操作时,由于引入了锁,一次只能一个进程执行,但进程的切换又有开销,所以光讲效率,其本身性能还是不如单进程的,但其实现了其他部分的代码并行,我学到现在理解的计算机整门学科都是妥协的艺术。

总结

本章学习了基于socket套接字的网络编程,主要流程是确定协议,绑定IP和端口,服务器端与客户端建立间接,最后是通信过程。为了在此基础上实现更多功能,比如建立更多连接和同时收发功能等引出了并发编程,有进程,线程这两个操作系统中实际存在的概念,还有代码层面用户在线程内实现并发的协程,而又因为多进程可能会引发数据的混乱问题,学习了同步异步的相关操作,主要是通过加锁的方式牺牲效率,实现了对共享区的互斥访问保证了数据准确性。总的来说知识不难,但很多地方可以深究,锁的检测与接触,与进程调度算法等操作系统的知识也可以和本章内容结合,就算学完了以后我也不好说用户和操作系统在程序运行过程中都进行了哪些交互。