语言概念与机制

5. python语言概念与机制

5.1 谈下python的GIL

难度指数: ★★★
重要指数: ★★★

如果面试时被问及python的GIL,那么你应当从以下2个方面回答

  1. 什么是GIL
  2. 对多线程的影响

5.1.1 什么是GIL

GIL,即全局解释器锁 ,一个被广泛吐槽的技术,即便python3.9已经到来,这个GIL依然存在,而且似乎将继续存在。

为了解决多线程之间数据完整性和状态同步的问题,引入了GIL, 它可以保证多个原生线程不会并发执行 Python 字节码。

对于GIL的理解,有一点我们必须严肃的弄清楚,GIL并不是python的特性,而是实现Python解析器(CPython,我们平时默认下载安装使用的解释器)时所引入的一个概念,这个就好比C++是一套语言标准,但可以用 GCC,INTEL C++,Visual C++ 等编译器来编译,编译出来的可执行程序也就不一样。你可以使用 CPython,PyPy , JPython 来执行python的代码,CPython里有GIL,而JPython 就没有。

Python 的维护者们不是没考虑过将GIL移除,可是已经有太多的库依赖这个特性,积重难返。

5.1.2 对多线程的影响

多线程原本是为了更好的利用CPU的多核资源,但是由于GIL的存在,使得同一个时刻,只能有一个python的线程在运行,显然,这是对资源的无耻浪费。

因此,你无法将python的多线程作为提升程序性能的一种手段,对于CPU密集型任务,请使用多进程。虽然如此,python的多线程也并非如大家所吐槽的那样鸡肋,对于I/O密集型任务,python多线程仍然有用武之地。

当一个线程I/O等待时,会释放GIL,这样其他线程就可以获得GIL来执行自己的任务,这就是你编写多线程爬虫加快爬取速度的技术依据。

5.1.3 总结

最后做如下总结,面试的时候按照以下要点回答

  1. GIL是全局解释器锁,以保证多个原生线程不会并发执行 Python 字节码,解决多线程之间数据完整性和状态同步的问题
  2. GIL并不是python的特性,而是实现Python解析器时所引入的一个概念,我们默认下载使用的CPython有GIL,而JPython就没有
  3. 同一个时刻,只能有一个线程在运行,GIL导致不能有效利用CPU的多核
  4. 对于CPU密集型任务,使用多进程,对于I/O密集型任务,可以考虑使用多线程

5.2 简述python垃圾回收机制

难度指数: ★★★★
重要指数: ★★★★

回答这个问题,要抓住3个要点

  1. 引用计数机制
  2. 标记清除
  3. 分代回收

5.2.1 引用计数

python对象的核心则是一个结构体

typedef struct_object {
 int ob_refcnt;
 struct_typeobject *ob_type;
} PyObject;

当一个对象有新的引用时,ob_refcnt加1,引用它的对象被删除时,ob_refcnt减1,当ob_refcnt=0时,这个对象的生命就结束了,这时,垃圾回收机制就会启动将这个对象回收。引用计数的机制是不受标记清除和分代回收影响的,只要引用计数为0,对象就会被回收。

5.2.2 标记清除

引入标记清除机制,是为了解决循环引用的问题,下面是一个简单的循环引用的示例

dict_1 = {}
dict_2 = {}

dict_1['a'] = dict_2
dict_2['b'] = dict_1

由于循环引用的存在,使得对象的引用计数在程序运行期间始终不为0,内存无法被释放。

标记清除分为两个阶段,第一阶段是标记阶段。GC会把所有活动对象打上标记,这些活动的对象就如同一个点,他们之间的引用关系构成边,最终点个边构成了一个有向图;2阶段,搜索清除阶段,从根对象(root)出发,沿着有向边遍历整个图,不可达的对象就是需要清理的垃圾对象。这个根对象就是全局对象,调用栈,寄存器。

5.2.3 分代回收

分代回收建立标记清除的基础之上,是一种以空间换时间的操作方式。标记清除可以回收循环引用的垃圾,但是,回收的频次是需要控制的,如果时时刻刻都回收,势必会影响python程序的性能。

分代回收,根据内存中对象的存活时间将他们分为3代,新生的对象放入到0代,如果一个对象能在第0代的垃圾回收过程中存活下来,GC就会将其放入到1代中,如果1代里的对象在第1代的垃圾回收过程中存活下来,则会进入到2代。当分配对象的个数减去释放对象的个数的差值大于700时,就会产生一次0代回收, 10次0代回收会导致一次1代回收, 10次1代回收会导致一次2代回收。通过gc.get_threshold() 和 gc.set_threshold 可以获取和设置触发分代回收的临界条件。

5.3 什么是PYTHONPATH

难度指数: ★★
重要指数: ★★★

PYTHONPATH 是环境变量,你可以在终端通过export命令对其进行设置,类似下面这样

export PYTHONPATH=/root/studyflask

这样设置的环境变量,是暂时的,终端关闭后就失效了,如果想让在这台机器上生效,可以/etc/profile,或者.bashrc中进行设置,这和你设置PATH环境变量是一样的方法。

设置PYTHONPATH的目的是为了改变sys.path 的值,影响python程序执行时import模块的过程。像上面那样设置PYTHONPATH后,打开交互式解释器

>>> import sys
>>> sys.path
['', '/root/studyflask', '/root/.pyenv/versions/3.6.5/lib/python36.zip', '/root/.pyenv/versions/3.6.5/lib/python3.6', '/root/.pyenv/versions/3.6.5/lib/python3.6/lib-dynload', '/root/.pyenv/versions/3.6.5/lib/python3.6/site-packages']

你可以看到,刚刚设置的目录放在了sys.path列表的第2个索引位置,当程序执行import命令引入模块时,会先从当前文件目录进行查找,招不到时,从/root/studyflask目录进行查找,以此类推。

除了设置PYTHONPATH可以修改sys.path外,你也可以在程序里直接对sys.path进行修改,或者创建.pth文件,在site-packages目录新建一个.pth文件,并在文件中加入搜索模块的路径

/root/test

那么这个搜索路径也会出现在sys.path中。通过设置PYTHONPATH环境变量,会添加路径到sys.path列表中靠前的位置,而通过编写.pth文件 添加的路径会放在sys.path靠后的位置。

关于搜索路径,建议你阅读python修改sys.path的三种方法

5.4 import时,python是如何寻找模块的

难度指数: ★★★
重要指数: ★★★
  1. 去sys.modules中寻找
  2. sys.modules中没有找到,则使用sys.meta_path里的importer继续查找,这些importer先会查找内置模块,然后查找frozen模块,最后会根据sys.path里的路径进行查找

我们需要关心的是sys.path里的内容,python脚本在执行时,会将当前路径插入到sys.path列表中索引为0的位置,因此这个目录是sys.path中最先被搜索的目录,你自己代码里的模块就是在这里被搜索到的。

sys.path的第二部分是安装python时内置进去的,比如

/Library/Frameworks/Python.framework/Versions/3.6/lib/python36.zip
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/lib-dynload
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages

sys.path的第三部分是你安装的第三方模块

更详细的讲解,参见import 原理

5.5 什么是Python包?

难度指数: ★★
重要指数: ★★

想要理解python包的概念,首先要理解python模块的概念。为了提高代码的复用率,我们将一组相关的python对象放在一个.py 文件中,他们可能是一些函数,或者是一些类,这个脚本就是一个模块。

包的概念,是建立在模块的概念之上的。实践中,会按照模块的用途放进不同的文件夹中,比如和数据库相关的模块都放在db文件夹中,和项目配置相关的模块会被放进conf文件夹中,这些文件夹中都会有一个__init__.py 文件,这些文件夹,就是所谓的包。Python 允许用户把目录当成模块看待。

5.6 简述你对python的理解

难度指数: ★★★
重要指数: ★★★

这个题目过于开放,因此也就没有所谓的标准答案,你只要回答出一些关键点即可,如果面试官就某一个关键点继续提问,这就要看你对关键点的理解层次了

  1. python是解释型语言,大家都是这样说的,但其实不准确。c语言编写的代码需要事先编译,这是编译型语言,ruby是一边解释,一边执行,是典型的解释型语言,java这种有虚拟机概念的语言,是先编译后解释,其实python也属于这种,.pyc 文件就是python编译所留下来的证据
  2. python是动态类型语言,在程序运行期间才会对数据类型进行检查,在编写代码时,你无需指定变量的数据类型。
  3. 一切皆对象。你所熟悉的1, 2, 3 这些int类型的数据是对象,你定义的函数,是对象,包括你所定义的class 也是对象,关于这个概念,可以参考文章一切皆对象
  4. python的最大优势在于活跃开放的社区,仅从性能上看,python是不如java,php的,python之所以给人一种无所不能的感觉,是因为它有一个庞大的活跃的开源社区,为我们提供各种开源的库,这些开源的代码避免了重复造轮子,对于系统的原型开发非常有利,大大节省了工程开发的时间。
  5. python也有自己明显的短处,这也是弱类型语言的通病,由于变量无需事先指定类型,这导致python的代码在大型项目里难以维护,尽管现在的python引入了类型标注,也并没有很好的解决这个问题
  6. python的执行效率确实低,不过这不是什么致命的问题,对于系统中对性能要求较高的部分,可以使用c或者c++来进行扩展。

5.7 谈一下你对python自省能力的理解

难度指数: ★★
重要指数: ★★★

自省就是能够获得自身的结构和方法,给开发者可以灵活的调用,给定一个对象,返回该对象的所有属性和函数列表,或给定对象和该对象的函数或者属性的名字,返回对象的函数或者属性实例。

你可以使用dir来查看一个对象的详细信息,包括他的属性和拥有的方法。python专门提供了一个inspect模块,提供了很多自省的能力,inspect.getsource甚至可以返回一个函数的源代码。

5.8 怎样理解Python中单下划线和双下划线

难度指数: ★★
重要指数: ★★★

在python中,单下划线有很特殊的作用

  1. 在交互式解释器中保存最后一个表达式的值
  2. 指向被忽略的值
  3. 字面上分割数字
  4. 单下划线用来定义私有的变量,函数,方法,类,一旦使用的单下划线开头做定义,在import * 这种引入模式中就不会被引入
  5. 单下划线结尾的情况,并不多见,它主要是为了避免和python中的关键字冲突
  6. 双下划线通常在定义类的方法时使用,它是一种语法,约定的属性相对弱一些,双下划线开头的属性和方法被认为是私有属性和私有方法。
  7. 双下划线开头,双下划线结尾的方法,是魔法方法,比如__init__ 和 __new__

参见文章 下划线(_)在python中的作用

5.9 说说你对协程的理解

难度指数: ★★★
重要指数: ★★★★

协程并不是python专有的概念,而是一个计算机概念。从概念上讲,python的生成器就是协程,为啥这样说呢,我们来看一下wiki上对协程的介绍:
协程(英语:coroutine)是计算机程序的一类组件,推广了协作式多任务的子程序,允许执行被挂起与被恢复。

生成器就符合协程的定义,关键点在于生成器允许执行被挂起和被恢复。在生成器里,代码执行到yield之后,会将程序的控制权交出去,在交出控制权后,仍然可以被恢复,再次执行。这一点,函数是做不到的,函数遇到return语句后,就退出了,无法被恢复。

协程,始终是在一个线程内被调度,这也是协程在并发方面优于线程的原因,因为协程减少了线程的上下文切换,那么协程是如何实现的呢,我有一篇文章专门讲解使用生成器实现协程,参见理解python的协程

5.10 如何理解闭包的概念

难度指数: ★★★
重要指数: ★★★★

在一个内部函数中,对外部作用域的变量进行引用,(并且一般外部函数的返回值为内部函数),那么内部函数就被认为是闭包,以下面的代码为例

import time

def cost(func):
    def wrapper():
        t1 = time.time()
        result = func()
        t2 = time.time()
        print(f"{func.__name__}执行时长: {t2-t1}")
        return result
    return wrapper


def test():
    time.sleep(1.5)

new_test = cost(test)
new_test()

函数wrapper内使用了外部作用域(函数cost所形成的作用域)中的func,且外部函数(cost函数)的返回值是内部函数(wrapper函数),函数wrapper就是一个闭包。

闭包的概念,牵扯到变量作用域的概念,也是构建装饰器的基础。闭包这种特性主要有两种作用

5.10.1 当闭包执行完后,仍然能够保持住当前的运行环境

下面是网络上流传最广的例子

origin = [0, 0]  # 坐标系统原点


def create(pos=origin):
    def player(direction, step):
        new_x = pos[0] + direction[0] * step
        new_y = pos[1] + direction[1] * step
        pos[0] = new_x
        pos[1] = new_y
        return pos

    return player


player = create()  # 创建棋子player,起点为原点
print(player([1, 0], 10))  # 向x轴正方向移动10步
print(player([0, 1], 20))  # 向y轴正方向移动20步
print(player([-1, 0], 10))  # 向x轴负方向移动10步

create函数返回的是player函数,函数执行一次后,会保存当前所在的位置,因此下一次执行player时,是从上一次停留位置开始的。之所以能够做到保留上一次停留的位置,是因为player是一个闭包,它内部使用了外部作用域的pos对象,而pos对象是调用create函数时默认传入的origin。

在函数式编程中,这是一种非常有效的设计方案,不过我不建议你太深入的研究和学习,原因在于面向对象编程在解决这类问题时能够提供更好的解决方案。

5.10.2 包可以根据外部作用域的局部变量来得到不同的结果

设计一个函数,可以从文本文件中提取包含特定关键词的数据

def make_filter(keep): 
    def the_filter(file_name):
        file = open(file_name)
        lines = file.readlines()
        file.close()
        filter_doc = [i for i in lines if keep in i]
        return filter_doc

    return the_filter


filter = make_filter("pass")        # filter是函数 the_filter
filter_result = filter("result.txt")

make_filter函数返回函数the_filter,the_filter函数会从文件中提取包含pass关键字的数据。

仔细看上面所举的两个例子,有没有发现他们和装饰器很像?其实它们从形式上和装饰器一模一样,只不过外部函数传入的参数是字符串,列表等基础类型的数据,如果传入的是函数对象,那么就是装饰器。

5.11 你会如何设置很多项目,其中每一个使用Python的不同版本和第三方库

难度指数: ★
重要指数: ★★

这是一个送分题,假设你有多个项目,有的项目用Python2, 有的项目用python3, 即便python版本相同,同一个第三方库的版本也可能会不同,在同一台电脑上,如何让他们使用各自的python解释器和不同版本的第三方库呢?

virtualenv 能否为不同的项目创建各自的虚拟环境,在这个虚拟环境中,你的项目有自己的python解释器,有自己的第三方库安装环境,这是一个独立的环境,与其他虚拟环境隔离。

面试官可能会继续提问,创建虚拟环境的原理是什么,原理其实非常简单,想要进入虚拟环境,必须执行命令source venv/bin/activate, 那么我们来看一下venv/bin/activate 这个文件里的内容,其中有这么一段

VIRTUAL_ENV="/root/test_env/venv"
export VIRTUAL_ENV

_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/bin:$PATH"
export PATH

这个文件中,将虚拟环境的目录添加到PATH的最前面,source命令让这个配置生效,那么对于当前你所打开的终端来说,当你再次执行python命令时,就从PATH里的路径去寻找python解释器,而由于你所创建的虚拟环境路径被插入到PATH的最前面,因此首先会到这里寻找,在venv目录下,有一个完整的python环境,这就是虚拟环境的秘密。

每一个venv目录下都有一个完整的python环境,在进入虚拟环境后,PATH被临时修改,使得当前的虚拟环境里的python总是第一个被找到。

5.12 编写优质python代码,你有什么经验

难度指数: ★★
重要指数: ★★★

首先,我们要回答一些基本的代码编写规范

  1. 变量名,函数名均使用小写英文字母,单词之间使用下划线连接,采用蛇形命名法
  2. 类名使用驼峰命名法,单词首字母大写,单词之间无连接符
  3. 函数里的代码行数一般不超过50行
  4. 要有完善的代码注释
  5. 变量命名要见其名,知其意,不使用无意义的变量名

然后再回答一些代码的技巧

  1. 使用enumerate代替range(len())
  2. 使用列表推导式
  3. 使用生成器节省内存
  4. 使用get方法和setdefault方法获取和设置字典的value
  5. 使用 f-Strings 格式化字符串
  6. 使用jion方法连接字符串
  7. 使用解包操作简化代码

关于如何写出优质的python代码,参见文章编写优质python代码的10个技巧

5.13 Python是如何进行内存管理的?

难度指数: ★★★★
重要指数: ★★★

这个问题,要从两个方面进行回答

  1. 垃圾回收机制
  2. 内存池机制

关于第一点,你可以参考5.2 ,内存池机制,是为了加速python的执行效率,对于Python对象,如整数,浮点数和List,都有各自的内存池,对于整数,从-5到255,这些小整数都是常驻内存的。

python的字符串nicode来表示, 但一个unicode字符最多占用4个字节,这样做有一些浪费,python在这方面做了优化,简单来说,一个python字符串里的字符,在内存中究竟占用多少字节,根本上取决于字符串里占用字节数最多的那一个。如果字符串里的字符都是ascii码表里的字符,那么每个字符就只占一个字节,如果字符串里出现了一个汉字,则每个字符占用2个字节,字符串节省内存的方式,参见文章Python在存储字符串时如何节省内存

对于列表,和元组,python也有优化,空的元组,永远只有一个;为了减少内存碎片,加快分配速度,python会重用旧的元组,如果一个元组不再被使用且元组的长度小于20,那么python不会直接释放它。

python里的列表和C++中的vector很像,看似有无限的空间可以使用,但其实,他们总是预先分配一些容量,当存储的数据超过容量时,则采取一定的算法增加容量,这样做可以避免过于频繁的申请内存,又能保证插入效率,关于列表和元组的内存优化,参见文章python 列表, 元组内存分配优化

5.14 Python中pass语句的作用是什么?

难度指数: ★
重要指数: ★

pass主要作用就是占位,让代码整体完整,假设你定义了一个函数,但一时没有想好其中的逻辑,或者一个编写if语句时并没有想好符合条件后的操作,都可以使用pass来占位。

5.15 Python解释器种类以及特点

其实这样的题目只是为了考察你对于整个python语言生态的了解而已,不是很重要的题目

  1. CPython, 由C语言开发的 使用最广的解释器,在命名行下运行python,就是启动CPython解释器.
  2. IPython, 基于cpython之上的一个交互式计时器 交互方式增强 功能和cpython一样
  3. PyPy, 目标是执行效率 采用JIT技术 对python代码进行动态编译,提高执行效率
  4. JPython, 运行在Java上的解释器 直接把python代码编译成Java字节码执行
  5. IronPython, 在微软 .NET 平台上的解释器,把python编译成. NET 的字节码

5.16 Python有哪些特点和优点?

难度指数: ★
重要指数: ★

虽然没有问缺点,但也要回答哦
优点:

  1. 开源免费
  2. 解释型语言,跨平台
  3. 胶水语言,可以很容易和其他编程语言结合使用,可扩展性强
  4. 开源社区获取,有大量开源库可供使用
  5. 语法简单明了,少量代码就可以完成其他语言大量代码才能完成的事情

缺点:

  1. 运行速度慢
  2. 代码加密困难

5.17 简单介绍一下断言

难度指数: ★
重要指数: ★

assert()方法,用于判断一个表达式,在表达式条件为False 的时候触发异常,终止程序的运行。你最好只在debug的时候使用断言,千万不要在生产环境里用。

a = 4
b = 3

assert a < b        # 抛异常,退出运行
print("ok")

扫描关注, 与我技术互动

QQ交流群: 211426309

加入知识星球, 每天收获更多精彩内容

分享日常研究的python技术和遇到的问题及解决方案