非死锁缺陷主要可以概括为以下几个:
「违反原子性」是指违反了多次内存访问中预期的可串行性,也就是说临界区代码没有被锁保护起来(即代码段本意是原子的,但在执行中并没有强制实现原子性)。如下面的代码,解决方法也就是加锁:
1 | Thread 1:: |
两个内存访问的预期顺序被打破了(即 A 应该在 B 之前执行,但是实际运行中却不是这个顺序)比如下面的这段代码中,如果 mState = mThread->State 语句先执行,则 mThread 为空。可以通过加条件变量(或信号量)解决:
1 | Thread 1:: |
除了上面的缺陷外,死锁(deadlock)死一种并发系统中经典的问题。比如下面的代码就可能会出现死锁:
1 | Thread 1: |
文件系统
依赖虚拟内存系统
申请一页内存,以便存放读到的块。1 | Vector v1, v2; |
在内部,这个方法需要多线程安全,因此针对被添加向量(v1)和参数(v2)的锁都需要获取。假设这个方法,先给 v1 加锁,然后再给 v2 加锁。如果另外某个线程几乎同时在调用 v2.AddAll(v1),就可能遇到死锁。
trylock()
函数会尝试获取锁,当锁被占有时则返回-1。下面是一个「比较并交换」的例子,在 *address
的值等于 expected
值时,将其赋值为 new:
1 | int CompareAndSwap(int *address, int expected, int new) |
假定我们想原子地给某个值增加特定的数量:
1 | void AtomicIncrement(int *value, int amount) |
一个更复杂的例子:链表插入。这是在链表头部插入元素的代码:
1 | void insert(int value) |
通过加锁解决同步:
1 | void insert(int value) |
用比较并交换指令(compare-and-swap)来实现插入操作:
1 | void insert(int value) |
这段代码,首先把 next 指针指向当前的链表头(head),然后试着把新结点交换到链表头。但是,如果此时其他的线程成功地修改了 head 的值,这里的交换就会失败,导致这个线程根据新的 head 值重试。
除了死锁预防外,有的场景死锁避免(avoidance)可能会更加合适,通过合理的调度避免产生死锁。比如下面的这个例子中,只要 T1 和 T2 (需要同一个锁的线程)不同时运行,就不会产生死锁。但这样就失去了并发性
最后一种常用的策略就是允许死锁偶尔发生,检查到死锁时再采取行动。
提示:不要总是完美(TOM WEST 定律)
Tom West 是经典的计算机行业小说《Soul of a New Machine》的主人公,有一句很棒的工程格言:“不是所有值得做的事情都值得做好”。如果坏事很少发生,并且造成的影响很小,那么我们不应该去花费大量的精力去预防它
。当然,如果你在制造航天飞机,事故会导致航天飞机爆炸,那么你应该忽略这个建议。
很多数据库系统使用了死锁检测和恢复技术。死锁检测器会定期运行,通过构建资源图来检查循环。当循环(死锁)发生时,系统需要重启。如果还需要更复杂的数据结构相关的修复,那么需要人工参与。
本章中学习了并发编程中出现的缺陷的类型。同时也简要的讨论了死锁的发生和处理。死锁和并发编程可谓是想生相伴,在计算机发展的历史中,在一些通用库和系统中(比如Linux),都已经有了一些无等待的实现,但这仍然不够通用,并且设计一个新的无等待数据结构极为复杂,以至于不实用。也许,我们可以换个角度来看,一种新的无需任何锁的并发编程模型或许才是最好的解决方案。
]]>信号量只是将锁和单值条件的条件变量封装在一起,所以它不是一个全新的概念,它能实现的事锁加条件变量都能实现。对于比较复杂情况下的条件判断无法使用信号量解决,因为其只内置了一个简单的整型的 value 条件。
信号量是有一个整数值的对象,可以用两个函数来操作它。在 POSIX 标准中,是sem_wait()
和 sem_post()
。
1 |
|
sem_init 用于初始化信号量,其中通过第三个参数将它的值初始化为1;第二个参数表示信号量是在同一个进程的多个线程共享的,我们可以看到在所有的例子中都设置为0,这个参数的值涉及到信号量的其他用法(比如跨进程的同步访问)。信号量初始化后,可以调用sem_wait()
和 sem_post()
来使用:
1 | int sem_wait(sem_t *s) { |
立刻返回
(调用 sem_wait()时,信号量的值大于等于 1
),同时信号量减1
,要么会让调用线程挂起
,直到之后的一个 post 操作。增加信号量
的值,如果有等待线程,唤醒
其中一个。负数
时,这个值就是等待线程
的个数首先我们看看信号量的第一种用法:用信号量作为锁。下面的代码中我们直接在临界区中用一对 sem_wait()/sem_post()
来包裹。但为了使这段代码正常工作,信号量的初始化是至关重要的。因为信号量是一个有整数值的对象,因此初始值应该是 1。代码如下:
1 | sem_t m; |
举一个具体的例子,假设一个线程创建另外一线程,并且等待它结束:
1 | sem_t s; |
我们再尝试通过信号量来解决上一章提到的「生产者/消费者」问题,下面的代码通过三个信号量的组合使用解决了互斥与死锁两个问题,代码如下:
1 | sem_t empty; |
并发编程中另一个经典的问题是源于对「更加灵活的锁定原语」的渴望,也就是说对更细粒度的锁的渴望。比如一个并发链表有很多插入和查找操作。插入会修改链表的状态,而查找只是读取。也就是说其实只要没有插入操作,我们可以并发的执行多个查找操作。
笔者认为所谓「更加灵活的锁定原语」或「如何提高锁的性能」等问题,其实都可以说是减少临界区代码,因为临界区代码其实只是并发编程中的一段「临时串行」的代码。而提高锁的性能、或者更加灵活的锁定原语,其实就是通过技巧来减少「临界区代码」,从而提高锁的效率。
比如:并发队列中将一把大锁拆分成入队/出队两把锁,从而提高了并发效率。这其实就是常说的减少锁的粒度
假定有 5 位“哲学家”围着一个圆桌。每两位哲学家之间有一把餐叉(一共 5 把)。哲学家有时要思考
一会,不需要餐叉;有时又要就餐
。而一位哲学家只有同时拿到了左手边和右手边的两把餐叉
,才能吃到东西。如下图:
我们将上面提到的过程,用代码转译一下:
1 | while (1) { |
因此我们可以看到,关键的挑战就是如何实现 getforks()
和 putforks()
函数,保证没有死锁,没有哲学家饿死,并且并发度更高(尽可能让更多哲学家同时吃东西)。我们首先定义一下这两个函数:
1 | int left(int p) { return p; } |
我们现在都知道,遇到并发问题第一个是加锁来解决,那么我们为每个动作都加锁:
1 | void getforks() { |
但这个方案有一个问题,会形成死锁,每个人都拿着左手边的叉子就无法继续了(循环等待)。而解决这个问题最简单的方法,就是修改取餐叉的顺序,比如最后一个人先取右边的餐叉,然后再拿左边的,从而打破死锁(循环等待):
1 | void getforks() |
最后我们用底层的同步原语(锁和条件变量),来实现自己的信号量:
1 | typedef struct _Zem_t |
信号量是编写并发程序强大而灵活的原语,信号量基于锁和条件变量,可以实现两者的功能。
比如在很多情况下,线程需要检查某一条件(condition)满意之后,才会继续运行。比如父线程等待子线程的join
函数。下面是一段自旋等待的代码:
1 | void *child(void *arg) |
这个方案无疑是能解决问题的,但效率比较低,因为主线程会自旋检查,浪费 CPU 时间。那么如果有一种方法可以让父线程休眠,直到等待的条件满足才唤醒执行,无疑会更加高效。
而这样的一种锁和休眠等待的组合,已经为我们封装好了。线程可以使用条件变量(condition variable),来等待一个条件变成真。
条件变量是一个显式队列,当某些执行状态(即条件,condition)不满足时,线程可以把自己加入队列,等待(waiting)该条件。另外某个线程,当它改变了上述状态时,就可以唤醒一个或者多个等待线程(通过在该条件上发信号),让它们继续执行。具体用法如下:
1 | // 声明 |
wait()调用有一个参数,它是互斥量mutex。
条件变量一般要与锁一起使用,wait()
调用时,这个互斥量必须是已上锁状态(处于临界区内)。wait() 的职责是释放锁,并让调用线程休眠(原子地)。当线程被唤醒时(在另外某个线程发信号给它后),它必须重新获取锁(重新进入临界区),再返回调用者。
实际应用时由条件和锁共同决定临界区,如果获得锁就进入临界区,但如果条件未满足,则暂时放弃锁退出临界区,直到被唤醒(且条件满足)时重新上锁进入临界区。典型的条件变量用法(伪代码):
1 | lock(&mutex);//进入临界区,保护done及其他临界资源 |
需要注意:发信号时总是持有锁,尽管并不是所有情况下都严格需要,但有效且简单的做法,还是在使用条件变量发送信号时持有锁。虽然上面的例子是必须加锁的情况,但也有一些情况可以不加锁,而这可能是你应该避免的。因此,为了简单,请在调用 signal
时持有锁
(hold the lock when calling signal)。
这个提示的反面,即调用 wait 时持有锁,不只是建议,而是 wait 的语义强制要求的。因为 wait 调用总是假设你调用它时已经持有锁、调用者睡眠之前会释放锁以及返回前重新持有锁。因此,这个提示的一般化形式是正确的:调用 signal 和 wait 时要持有锁(hold the lock when calling signal or wait),你会保持身心健康的。
本章要面对的下一个问题,就是生产者/消费者问题(producer/consumer),有的也叫「有界缓冲区(bounded buffer)」问题。
1 | cond_t cond; |
可以看到上面的代码中使用 if
作为判断显然是不合理的,具体原因我写在注释中了。为此我们可以总结出一个关于条件变量使用的简单规则:「总是使用 while 循环
(always use while loop)」。
其次便是通知是需要指向性的,消费者不应该唤醒消费者,而应该只唤醒生产者,反之亦然。
下面我们使用 while
来修改代码:
1 | int buffer[MAX]; |
下面来看一个例子,这是一个简单的多线程内存分配库,以下代码用于内存分配管理,free 后会唤醒 allocate 时因空间不够而等待的线程:
1 | // how many bytes of the heap are free? |
考虑以下场景:假设目前没有空闲内存,线程 Ta 调用 allocate(100)
,接着线程 Tb 请求较少的内存,调用 allocate(10)
。Ta 和 Tb 都等待在条件上并睡眠,没有足够的空闲内存来满足它们的请求。
假定第三个线程 Tc 调用了 free(50)
,当他发出信号唤醒等待线程时,因为不知道唤醒哪个线程,可能不会唤醒申请 10 字节的 Tb 线程,而是唤醒 Ta 线程,但该线程由于内存不够仍然等待。因此图中的代码无法正常的工作。
解决的方案也很直接:用使用 pthread_cond_broadcast()
唤醒所有等待的线程。这样做,确保了所有应该唤醒的线程都被唤醒。当然,不利的一面是可能会影响性能。Lampson 和 Redell 把这种条件变量叫作覆盖条件(covering condition),因为它能覆盖所有需要唤醒线程的场景(保守策略)
这一章引入了锁之外的另一个重要同步原语:条件变量。当某些程序状态不符合要求时,让线程进入休眠状态,避免不必要的空转(spin)
]]>需要注意,条件变量并不是不需要锁。
下面先看一个简单的非并发的计数器,然后一步步的在此基础上构建一个并发安全且高性能的计数器:
1 | typedef struct counter_t |
可以看到计数器的代码非常简单,而我们想其并发安全显而易见的方法便是在结构体中添加一把锁,在数据做修改操作的临界区代码添加上这把锁,修改后的代码如下:
1 | typedef struct counter_t |
这样做在多CPU环境下性能很差,因为这种锁导致了多 CPU 情况下也只允许一个线程在运行,其他都在自旋等待,没发挥出多 CPU 的优势。相当于退化成串行,并且还增加了锁的开销。理想情况下,虽然工作量增多,但并行执行后,完成任务的时间并没有增加。
为了解决上面的问题,有一种解决方法称为:懒惰计数器(sloppy counter)
懒惰计数器通过多个局部计数器和一个全局计数器来实现一个逻辑计数器,其中每个 CPU 核心有一个局部计数器。具体来说,在 4 个 CPU 的机器上,有 4 个局部计数器和 1 个全局计数器。除了这些计数器,还有锁:每个局部计数器有一个锁,全局计数器有一个。
懒惰计数器的基本思想是这样的。如果一个核心上的线程想增加计数器,那就增加它的局部计数器,访问这个局部计数器是通过对应的局部锁同步的。因为每个 CPU 有自己的局部计数器,不同 CPU 上的线程不会竞争,所以计数器的更新操作可扩展性好。
为了保持全局计数器更新(以防某个线程要读取该值),局部值会定期转移给全局计数器,方法是获取全局锁,让全局计数器加上局部计数器的值,然后将局部计数器置零
局部转全局的频度,取决于一个阈值,这里称为 S(表示 sloppiness)。S 越小,懒惰计数器则越趋近于非扩展的计数器。S 越大,扩展性越强,但是全局计数器与实际计数的偏差越大。
在这个例子中,阈值 S 设置为 5,4 个 CPU 上分别有一个线程更新局部计数器 L1,…, L4。随着时间增加,全局计数器 G 的值也会记录下来。每一段时间,局部计数器可能会增加。如果局部计数值增加到阈值 S,就把局部值转移到全局计数器,局部计数器清零。
1 | typedef struct counter_t |
我们和上面一样,先添加一把大锁保证了并发安全,然后再进一步探讨如何优化性能:
1 | // basic node structure |
从代码中可以看出,插入函数入口处获取锁,结束时释放锁。如果 malloc 失败(在极少的时候),会有一点小问题,在这种情况下,代码在插入失败之前,必须释放锁。
我们调整代码,让获取锁和释放锁只环绕插入代码的真正临界区(缩小临界区)。前面的方法有效是因为部分工作实际上不需要锁,假定 malloc()是线程安全的,每个线程都可以调用它,不需要担心竞争条件和其他并发缺陷。只有在更新共享列表时需要持有锁。下面展示了这些修改的细节。
1 | void List_Init(list_t *L) |
现在已经能保证这个链表的并发安全了,想要解决性能问题,可以使用一种叫过手锁(hand-over-hand locking,也叫锁耦合,lock coupling),其原理十分简单,就是每个节点都有一个锁,替代之前整个链表一个锁。遍历链表的时候,首先抢占下一个节点的锁,然后释放当前节点的锁。
从理论上来说确实有点合理,但实际上在遍历的时候,每个节点都要加锁、解锁,而这个开销也是十分巨大的,很难说比单锁的方法快。即使有大量的线程和很大的链表,也不一定就比单锁快,它只适用于十分单一且特殊的场景,或者夹杂在某些方案里面。
现在我们知道可以用标准的方法来创建一个并发安全的数据结构:添加一把大锁。现在队列这种数据结构其实和链表很像,只是队列的操作有限制,因此大锁这种方案我们跳过。我们来看看 Michael 和 Scott 设计的更并发的队列:
1 | typedef struct __node_t |
这段代码十分巧妙,用到了两个锁和一个哨兵节点。一个锁负责队头,另一个负责队尾,使得入队出队操作可以并发执行。而哨兵节点是在初始化的时候分配的,利用它分开了头和尾的操作。
最后讨论的是一个广泛应用的数据结构——散列表,为了突出重点,这里不对扩缩容做深入:
1 |
|
这个散列表使用我们上面实现的并发链表,性能特别好。每个散列桶(每个桶都是一个链表)都有一个锁,而不是整个散列表只有一个锁,从而支持许多并发操作,其实就是分段加锁了。
这个简单的并发散列表扩展性(性能)极好,相较于单纯的链表。原因就是缩小了临界区,减少了不同线程间操作的冲突。
并发其实并不意味着高性能,在实现并发数据结构时,先从最简单的开始(也就是加一把大锁),有性能问题的时候再做优化。关于最后一点,避免不成熟的优化(premature optimization),对于所有关心性能的开发者都有用。我们让整个应用的某一小部分变快,却没有提高整体性能,其实没有价值
]]>lock()和 unlock()函数的语义很简单。调用 lock() 尝试获取锁,如果没有其他线程持有锁(即它是可用的
),该线程会获得锁,进入临界区。当持有锁的线程在临界区时,其他线程就无法进入临界区。伪代码如下:
1 | lock_t mutex; |
POSIX 库将锁称为互斥量(mutex)使用代码如下:
1 | pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; |
控制中断是最早提供的互斥解决方案之一,其本质就是在执行临界区代码的时候关闭中断。这个方案是为单处理器系统开发的,代码如下:
1 | void lock() { |
这样的方案的优点是简单、清晰易懂。但缺点却很多:
因此基于上述的情况,只在少数的情况下用开/关中断来实现互斥原语,比如操作系统本身会采用屏蔽中断的方式,保证访问自己数据结构的原子性或者避免复杂的中断处理情况,因为在操作系统内部它总是可信的,不存在信任问题。
「原语」是什么?个人简单的理解,原语是指操作系统层面提供的指令,一个原语中可能有很多个底层的指令,但操作系统帮我们确保了这些指令以原子的方式执行。
锁的实现需要硬件支持,最简单的硬件支持是测试并设置指令(test-and-set instruction,TAS),也叫作原子交换(atomic exchange)。
1 | typedef struct lock_t { int flag; } lock_t; |
上述代码中,每次 lock 会判断 flag 的值,也就是测试并设置指令(test-and-set instruction)中的test,然后判断成功才 set
但如果没有硬件辅助,也就是让测试并设置作为一个原子操作,会导致两个线程有可能同时进入临界区。
注意自旋等待spin-wait会影响性能
自旋锁(spin lock)其实就是一直自旋(判断某个条件成立),直到锁可用。
在 SPARC 上,需要的指令叫 ldstub(load/store unsigned byte,加载/保存无符号字节);在 x86 上,是 xchg(atomic exchange,原子交换)指令。但它们基本上在不同的平台上做同样的事,通常称为测试并设置指令(test-and-set)。
1 | int TestAndSet(int *old_ptr, int new) { |
将测试并设置作为原子操作:
1 | typedef struct lock_t { |
现在我们来用之前提到的标准来评价基本的自旋锁:
某些系统提供了另一个硬件原语——比较交换指令(SPARC 中是 compare-and-swap,x86 中是 compare-and-exchange)。下面是伪代码:
1 | int CompareAndSwap(int *ptr, int expected, int new) { |
可以看到和测试并设置指令的工作方式类似,但其实它十分强大,这在后面讨论「无等待同步(wait-free synchronization)时,会用到这条指令的强大之处。但如果只是用它实现一个简单的自旋锁,那么它无疑等价于上面分析的自旋锁。
一些平台提供了实现临界区的一对指令:链接的加载(load-lionked)和条件式存储(store-conditional)可以用来配合使用,实现其他并发结构。
1 | int LoadLinked(int *ptr) { |
条件式存储(store-conditional)指令,只有上一次执行LoadLinked的地址在期间都没有更新时, 才会成功,同时更新了该地址的值
先通过 LoadLinked 尝试获取锁值,如果判断到锁被释放了,就执行StoreConditional判断在「执行完」LoadLinked到StoreConditional「执行前」ptr 有没有被更新,没有被更新则说明没有其他线程来抢,可以进临界区,有更新则说明已经被其他线程抢走了,继续重复本段落所述内容循环:
1 | void lock(lock_t *lock) { |
获取并增加(fetch-and-add)指令能原子地返回特定地址的旧值,并且让该值自增一。
1 | int FetchAndAdd(int *ptr) { |
这个方案使用了两个变量来构建锁(ticket和turn),基本操作也很简单:每次进入 lock,就获取当前ticket值
,相当于挂号,然后全局 ticket 本身会自增一,因此后续线程都会获得属于自己的唯一 ticket值,lock->turn表示当前叫号值,叫到号的运行。unlock 时递增lock->turn更新叫号值就行(也就是叫下一个号)。这种返回式保证了公平性,相当于每个线程排队运行(FIFO)。
一个线程会一直自旋检查一个不会改变的值,浪费掉整个时间片!如果有 N 个线程去竞争一个锁,情况会更糟糕。同样的场景下,会浪费 N−1 个时间片,只是自旋并等待一个线程释放该锁。
如何让锁不会不必要地自旋,浪费 CPU 时间?要解决这个问题,只有硬件支持是不够的,我们还需要操作系统的支持!
需要一个队列来保存等待锁的线程,上锁时发现锁已被持有,则入队并让调用线程休眠,解锁时从队列中取出一个线程唤醒。Solaris 中 park()
能够让调用线程休眠,unpark(threadID)
则会唤醒 threadID 标识的线程。
两阶段锁中如果第一个自旋阶段没有获得锁,第二阶段调用者会睡眠,直到锁可用。Linux 中的 futex 就是这种锁,不过只自旋一次;更常见的方式是在循环中自旋固定的次数(希望这段时间内能获取到锁),然后使用 futex 睡眠。
]]>本章解答的关键问题:如何创建和控制线程?
操作系统应该提供那些创建和控制线程的接口?这些接口如何设计得好用又实用?
1 | #include <pthread.h> |
上面的代码是 POSIX 中 创建线程的接口定义,该函数有 4 个参数,具体描述如下:
pthread_create()
以便初始化,相当于线程的唯一标识(身份证)通过pthread_join
阻塞等待线程完成
1 | // 创建进程 |
除了创建和等待线程之外,POSIX 线程库提供的最有用的函数集,可能就是通过锁(lock)来提供互斥的那些函数了。最基本的一对函数是:
1 | int pthread_mutex_lock(pthread_mutex_t *mutex); |
使用的代码大概是这样子:
1 | pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; |
注意锁必须正确初始化,上面的代码展示了两种上锁的方法(使用 PTHREAD_MUTEX_INITIALIZER 常量或者使用 pthread_mutex_init() 函数,一般情况下更推荐用第二种。
所有线程库还有一个主要组件,就是条件变量。当线程之间必须发生某种信号时,条件变量就很有用。比如一个线程在等待另一个线程继续执行某些操作。
1 | int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); |
要使用条件变量,必须另外有一个与此条件相关的锁。在调用上述任何一个函数时,应该持有这个锁。
第一个函数pthread_cond_wait()
使调用线程进入休眠
状态,因此等待其他线程发出信号,通常当程序中的某些内容发生变化时,现在正在休眠的线程可能会关心它。典型的用法如下所示:
1 | pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; |
唤醒线程
的代码运行在另外某个线程中,调用pthread_cond_signal
时也需要持有对应锁
。像下面这样:
1 | pthread_mutex_lock(&lock); |
pthread_cond_wait
有第二个参数,因为它会隐式
地释放锁
,以便在其线程休眠后唤醒线程可以获取锁,之后又会重新获得锁。
本例通过 while 判断 ready 的值的变更,而不是通过条件变量唤醒判断 ready 已变更。将唤醒视为某种事物可能已经发生变化的暗示,而不是绝对的事实,这样更安全
代码需要包括头文件 pthread.h 才能编译。链接时需要 pthread 库,增加 -pthread
标记。
1 | prompt> gcc -o main main.c -Wall -pthread |
本章介绍了基本的 pthread 库,包括线程创建、锁实现互斥执行、通过条件变量的信号和等待。如需更多信息,请在 Linux 系统上输入 man -k pthread
来获取。
当你使用 POSIX 线程库(或者实际上,任何线程库)来构建多线程程序时,需要记住一些小而重 要的事情:
OSTEP 的第二部分将介绍并发相关的内容,因此本章将首先介绍为单个运行进程提供的新抽象:进程(thread)。经典的观点是一个程序只有一个执行点(一个程序计数器,用来存放要执行的指令),但多线程(multi- threaded)程序会有多个执行点(也就是多个程序计数器,每个都用于取指令与执行)。换个角度来看,每个线程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据。
单个线程的状态与进程状态非常类似。线程有一个程序计数器(Program Counter),记录程序从哪里获取指令。每个线程
有自己的一组
用于计算的寄存器
。所以,如果有两个线程运行在一个处理器上,从运行一个线程(T1)切换
到另一个线程(T2)时,必定发生上下文切换(context switch)。线程之间的上下文切换类似于进程间的上下文切换。对于进程,我们将状态保存到进程控制块(Process Control Block,PCB)。现在,我们需要一个或多个线程控制块(Thread Control Block,TCB),保存每个线程的状态。但是,与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变(即不需要切换当前使用的页表)。
线程和进程之间另一个主要的区别在于栈。在传统的进程内存模型中,只有一个栈,通常位于地址空间的底部。而多线程的进程中,每个线程独立运行,因此地址空间中不只有一个栈,而是每个线程都有一个栈。如下图所示:
下面代码演示了一个简单的多线程程序:
1 |
|
这个代码中,主程序创建了两个线程,分别执行 mythread()
函数,但传参不一样。一旦线程创建,可能会立即运行或者处于就绪状态等待执行(这取决于调度程序)。下面三个表格中分别列举了几种可能的运行顺序:
情况一:
主程序 | 线程一 | 线程二 |
---|---|---|
| ||
| ||
等待线程 2 | ||
| ||
打印 "main:end" |
情况二:
主程序 | 线程一 | 线程二 |
---|---|---|
| ||
| ||
等待线程 1 | ||
| ||
|
情况三:
主程序 | 线程一 | 线程二 |
---|---|---|
| ||
| ||
创建线程 2 | ||
| ||
|
不难看出线程使得编程变得复杂:已经很难说出什么时候会运行了!
两个线程递增同一个数,每次运行最终结果都不一样?原因是共享数据未保证操作原子性
,因为代码中看似一行代码的「加加」,在真实执行时是由几个指令完成的,因此存在并发问题。
fork()
和 exec()
,进程间等待用的 wait()
在执行函数 fork()时,创建了一个子进程,此时是两个进程同时运行,fork 出来的进程称为「子进程」
1 |
|
输出如下:
1 | prompt> ./p1 |
上面这段程序执行了一次 fork 操作,fork()
函数是一个神奇的操作,它只被调用了一次,却产生了两个返回值。对于父进程
来说,其返回值是子进程的 pid;对于子进程
来说,其返回值为 0。
子进程并不是完全拷贝了父进程,所以子进程不会从 main 开始执行,该程序的首行打印并未被子进程执行。它拥有自己的「地址空间」(即拥有自己的私有内存)、寄存器、程序计数器等。
当然,父进程与子进程的执行顺序并不是绝对的,这都取决于调度器怎么调度,子进程也可能比父进程先执行完。
wait()
函数用于使父进程(也就是调用 wait()的进程)阻塞,直到一个子进程结束
或者该进程接收到了一个指定的信号为止。
1 |
|
1 | prompt> ./p2 |
这里因为父进程调用了wait()
方法,因此子进程会先于父进程执行完毕。如果父进程先执行时,会等待子进程结束,才会继续执行。
exec()
这个系统调用可以让子进程执行与父进程不同的程序
1 |
|
1 | prompt> ./p3 |
在这个例子中,子进程调用 execvp()
来运行字符计数程序 wc。实际上,它针对源代码文件 p3.c 运行 wc,从而告诉我们该文件有多少行、多少单词,以及多少字节。
给定可执行程序的名称(如 wc)及需要的参数(如 p3.c)后,exec()
会从可执行程序中加载代码和静态数据,并用它「覆写」自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。然后操作系统就执行该程序,将参数通过 argv 传递给该进程。因此,它并没有创建新进程,而是直接将当前运行的程序(以前的 p3)替换
为不同的运行程序(wc)。子进程执行 exec()之后,几乎就像 p3.c 从未运行过一样。对 exec()的成功调用永远不会返回。如果 exec 函数执行失败, 它会返回失败的信息, 而且进程继续执行后面的代码。
此时子进程的 pid 号并没有变,且还是该父进程的子进程,所以并不会影响 wait()操作,等待该进程的操作(统计字节)完成后,wait()才会返回,父进程同时退出阻塞状态
事实证明,这种分离 fork()及 exec()的做法在构建 UNIX shell
的时候非常有用,因为这给了 shell 在 fork 之后 exec 之前运行代码的机会,这些代码可以在运行新程序前改变环境,从而让一系列有趣的功能很容易实现。
shell 也是一个用户程序,它首先显示一个提示符(prompt),然后等待用户输入。你可以向它输入一个命令(一个可执行程序的名称及需要的参数),大多数情况下,shell 可以在文件系统中找到这个可执行程序,调用 fork()
创建新进程,并调用 exec()
的某个变体来执行这个可执行程序,调用 wait()
等待该命令完成。子进程执行结束后,shell 从 wait()返回并再次输出一个提示符,等待用户输入下一条命令。
fork()和 exec()的分离,让 shell 可以方便地实现很多有用的功能。比如:
1 | prompt> wc p3.c > newfile.txt |
在上面的例子中,wc 的输出结果被重定向(redirect)到文件 newfile.txt 中(通过 newfile.txt 之前的大于号来指明重定向)。shell 实现结果重定向的方式也很简单,当完成子进程的创建后,shell 在调用 exec()之前先关闭了标准输出(standard output),打开了文件 newfile.txt。这样,即将运行的程序 wc 的输出结果就被发送到该文件,而不是打印在屏幕上。
下面我们来简单看一下重定向的工作原理,是基于对操作系统管理文件描述符方式的假设,首先看代码实例:
1 |
|
要看懂上面的例子,首先要补充点Unix文件描述符的知识:
数值 | 名称 | <unistd.h>符号常量 | <stdio.h>文件流 |
---|---|---|---|
0 | Standard input | STDIN_FILENO | stdin |
1 | Standard output | STDOUT_FILENO | stdout |
2 | Standard error | STDERR_FILENO | stderr |
STDOUT_FILENO
输出到屏幕,此时所有的对标准输出文件描述符
的输出,如 printf()
,都会打印的屏幕上:1 | root@hjk:~/repo/os_test# ./a.out |
printf()
,系统会提示找不到文件描述符1 | root@hjk:~/repo/os_test# ./a.out |
1 | open("./p4.output", O_CREAT | O_WRONLY | O_TRUNC, S_IRWXU) |
UNIX管道也是用类似的方式实现的,但用的是 pipe()
系统调用。在这种情况下,一个进程的输出被链接到了一个内核管道(pipe)上(队列),另一个进程的输入
也被连接到了同一个管道上。因此,前一个进程的输出无缝地作为后一个进程的输入,许多命令可以用这种方式串联在一起,共同完成某项任务。比如通过将 grep
、wc
命令用管道连接可以完成从一个文件中查找某个词,并统计其出现次数的功能:
]]>TODO,目前在读第一遍,先粗读一遍,第二遍再精读与完成课后习题
我们在使用计算机的过程中,通常都会运行多个程序。比如:同时运行浏览器、播放器、游戏等等。除了这些我们熟知的程序外,系统内部还运行着上百个程序。现代的处理器一般都有多个核(注意,这里的核其实是指一个「运算单元」也就是一个 CPU,也就是说我们平时购买的一个物理的 CPU 里面,一般是有多个 CPU 的)。如果每个程序都独占一个CPU,那么就可能需要上百个「运算单元」,那么这样的 CPU 也太过昂贵了。
这也是操作系统需要解决的问题:「如何提供有许多CPU的假象」。操作系统通过虚拟化(virtualizing)CPU 来提供这种假象。通过让一个进程只运行一个时间片,然后切换到其他进程,操作系统就提供了存在许多 CPU 的假象了。通俗的来讲就是通过超快速的切换进程,从而达到让用户看起来每个进程都在同时运行。
首先看一下一个程序是如何转换为进程的。具体来说是操作系统是如何启动并运行一个程序的?
操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载(load)到内存中,加载到进程的地址空间中。简单的来说就是从持久化存储的「磁盘」导入到易失的「内存」中。(如下图所示)
在早起的操作系统中,加载的这个过程是尽早(eagerly)完成,即在运行程序之前全部完成。现代操作系统则是惰性(lazily)执行该过程,即仅在程序运行期间按需加载代码或数据片段。这个过程涉及到内存虚拟化,后面的对应章节会展开具体讲解。
加载到内存后操作系统在运行之前还会执行其他一些操作,比如为程序运行时的栈(run-time stack 或 stack)、堆(heap)、局部变量等分配一些内存。此外还会执行一些其他初始化任务,特别是输入输出(I/O)相关的任务,例如:stdin、stdout、stderr等等。最后会跳转到程序的入口,也就是 main()
函数,至此 OS 会将 CPU 的控制权转交到新创建的进程中,程序开始运行。
为此进程的创建过程总结如下:
main
函数)进程的三种状态:
上图展示了上述三种状态之间的转换关系,可以根据操作系统的载量,让进程在就绪状态和运行状态之间转换。从就绪 -> 运行意味着该进程已经被调度(scheduled)。从运行 -> 就绪意味着该进程已经取消调度(descheduled)。一旦进程被阻塞(例如,通过发起 I/O 操作),OS 将保持进程的这种状态,直到发生某种事件(例如,I/O 完成)。此时,进程再次转入就绪状态(也可能立即再次运行,如果操作系统这样决定)。
关于调度的策略,简单总结下,就是一个进程
阻塞或停止
时,就会去调度另一个
就绪
的进程
,让 CPU 一直保持在满负荷状态,从而提高 CPU 的使用率。
为了跟踪每个进程的状态,操作系统可能会为所有就绪的进程保留某种进程列表
(process list),以及跟踪当前正在运行的进程的一些附加信息。操作系统还必须以某种方式跟踪被阻塞的进程
。当 I/O 事件完成时,操作系统应确保唤醒
正确的进程,让它准备好再次运行。
那么既然操作系统也是个程序,并且是由C语言编写的,因此我们可以来看看进程在代码中的数据结构是啥样的,这无疑有利于我们对进程的理解:
1 | // the registers xv6 will save and restore |
该数据结构展示了 xv6 内核中每个进程的相关信息类型。这里用到 XV6 内核是因为它足够简单,是上手操作系统的一个不错选择 。 并且在“真正的”操作系统中存在类似的进程结构,如 Linux、macOS X 或 Windows。
除了运行
、就绪
和阻塞
之外,还有其他一些进程可以处于的状态:
初始(initial)状态
有时候系统会有一个初始(initial)状态,表示进程在创建时处于的状态。
最终(final)状态
另外,一个进程可以处于已退出但尚未清理的最终(final)状态(在基于 UNIX 的系统中,这称为僵尸状态
)。这个最终状态非常有用,因为它允许其他进程(通常是创建进程的父进程)检查进程的返回代码
,并查看刚刚完成的进程是否成功执行
(通常,在基于 UNIX 的系统中,程序成功完成任务时返回零,否则返回非零)。完成后,父进程将进行最后一次调用(例如,wait()),以等待子进程的完成,并告诉操作系统它可以清理这个正在结束的进程的所有相关数据结构
关于作业,本文只摘取部分我认为比较重要的部分:立即处理阻塞完成的进程是否是一个好主意?(也就是第六题)
-S SWITCH_ON_IO
参数,进程 0 进入阻塞状态,cpu 被切换到运行进程 1,当进程 0 的 IO 完成后,进程 1 继续执行,直到完成。也就是 IO 完成事件不会被立即处理,由于进程 0 的 IO 动作较为频繁,会使它长时间处于 IO 完成等待状态,导致后续的 IO 操作时 cpu 已经无事可做了,在本例条件下降低了效率,输出如下:1 | $ python3 ./process-run.py -l 3:0,5:100,5:100,5:100 -S SWITCH_ON_IO -I IO_RUN_LATER -c -p |
1 | $ python3 ./process-run.py -l 3:0,5:100,5:100,5:100 -S SWITCH_ON_IO -I IO_RUN_IMMEDIATE -c -p |
本系列文章将按照笔者阅读章节顺序编写,结合原文与自己的感悟,以作笔记之用,如有不足之处,恳请在评论区指出
操作系统主要的三个部分分别是:虚拟化(virtualization)、并发(concurrency)和持久化(persistence),这是本书主要学习的3个关键概念。通过学习这三个概念来理解操作系统这门课程。
国内许多教材/八股文对操作系统的描述可能与本书差异较大,但我个人感觉其实都是描述一个东西,相比之下教材上只是描述的更具体。比如进程/线程其实是操作系统实现虚拟化的其中一个手段的等等。
下面是一个简单的程序,它所做的只是循环每秒打印出用户在命令行中传入的字符串:
1 |
|
当我们在一个单处理器(cpu)的系统上编译并运行它,我们将看到以下内容:
1 | farmer:~/studys/operating-systems/ch02$ ./cpu A |
这个结果看起来十分合理不是吗?现在让我们运行同一个程序的不同实例,来看看结果:
1 | farmer:~/studys/operating-systems/ch02$ ./cpu A & ./cpu B & ./cpu C & |
现在事情开始有趣起来了,尽管只有一个处理器,但是这几个程序在我们用户的视角看来还是在同时运行的,看起来就像有多个处理器在同时执行这3个程序一样!
而这种将单个CPU(或其中一小部分)转换成看似多个CPU,从而让许多程序看似同时运行的技术,这就是所谓的虚拟化CPU(virtualizing the CPU)
看完了CPU让我们来看看内存,现代机器提供的物理内存(physical memory)模型其实非常的简单,就是一个字节数组而已。程序通过指定一个地址(address)来访问、写入或更新存在那里的数据。
程序运行时一直要访问内存,程序将所有数据结构保存在内存中,并通过各种指令来访问它们,因此每次读取指令都会访问内存。下面来看一个简单的demo:
1 |
|
该程序的输出如下:
1 | farmer:~/studys/operating-systems/ch02$ ./mem |
这个demo首先为 p 这个变量分配了一些内存(a1行)。然后打印出内存的地址(a2),然后将数字0放入新分配的内存(a3)的第一个空位中。最后程序循环,延迟一秒钟并递增 p 中保存的地址值。在每个打印语句中,它还会打印出正在运行程序的进程标识符(PID)。
同样的,我们再次运行多个实例来看看会发生什么:
1 | farmer:~/studys/operating-systems/ch02$ ./mem & ./mem & |
我们可以看到每个实例都在相同的地址(00200000)分配了内存,但每个似乎都独立更新了00200000处的值!就好像每个正在运行的程序都有自己的私有地址,而不是与其他正在运行的程序共享相同的物理内存。
而这真是操作系统虚拟化内存(virtualizing memory)时发生的情况,每个进程访问自己的私有虚拟地址空间(virtual address space)(有时称为地址空间,address space),操作系统以某种方式映射到机器的物理内存上,让正在运行的实例完全拥有自己的物理内存。
但实际情况是,物理内存是由操作系统管理的共享资源。而这也正是上文提到的「虚拟化」的内容。
并发是这本书的第二个部分,并发只是一个用来代指「同时处理很多事情」所带来一系列问题的代词,而这些问题是在同时(并发)处理很多事情时出现且必须解决的。
那么为什么并发通常出现在《操作系统》这门课中呢?其实是因为并发问题首先出现在操作系统中,而随着软件工程的发展,现代多线程(multi-threaded)程序也存在相同的问题。我们来看一个具体的多线程例子:
1 |
|
这个例子中利用Pthread_create()
创建了两个线程(thread),每个线程开始在一个名为worker()
的函数中运行,该函数中只是递增一个计数器,循环 loops 次。
下面是将变量loops的输入值设置为 1000 时的输出结果,根据代码我们可以很容易的猜到运行结果是 2000,因为每个线程会循环 1000 次并对计数器做一个累加的操作。也就是说当输入为 N 时,直观的预计结果为 2N:
1 | farmer:~/studys/operating-systems/ch02$ ./thread 1000 |
但如果对并发编程有点了解的话会发现这段代码中存在一个问题:计数器累加操作并非原子方式(atomically)的。让我们运行相同的程序,但 loops 的值更高,然后看看会发生什么:
1 | farmer:~/studys/operating-systems/ch02$ ./thread 100000 |
事实证明当我们将 loops 值设置的更高后,得到的最终值不是 200000。并且其最终值在每次运行中都是不一样的结果!
这些奇怪的,不合常理的结果与指令如何执行有关。上面程序中的关键部分是增加共享计数器(counter)的地方,它需要 3 条指令:一是将计数器的值翀内存中加载到寄存器,二是将其做递增操作,三是将其保存回内存。因为这三条指令并不是以原子的方式执行(所有的指令一次性执行)的,所以才会导致这些奇怪的事情发生。而这种问题通常叫:并发(concurrency)问题
我们都知道程序是运行在内存中的,如果发生断电或系统崩溃等情况,那么内存中的数据是容易丢失的。因为像 DRAM 这样的设备以易失(volatile)的方式存储数据,因此我们需要硬件和软件来持久化(persistently)的存储数据。这样的存储对于所有系统来说都十分重要,因为数据是无价的。
操作系统中管理硬盘的软件通常称为文件系统(file system)。因此它负责以可靠和高效的方式,将用户创建的任何文件(file)存储在系统的磁盘上。而不像 CPU 和内存一样需要操作系统提虚拟化,因为它是可以被多个程序所共有的。
1 |
|
上面的程序向操作系统发出了 3 个调用。一是 open()
调用,用来创建并打开一个文件。第二个是 write()
调用,将一些数据(「hello world\n」这串字符) 写入文件。第三则是 close()
调用来关闭文件,从而表明程序不会再向该文件写入更多的数据。
而上面提到的这些系统调用(system call)会被转到称为文件系统的操作系统部分,然后由文件系统处理这些请求,并向用户返回某种代码来表示结果。
首先确定新数据将驻留在磁盘上的哪个位置,然后在文件系统所维护的各种结构中对其进行记录。这样做需要向底层存储设备发出 I/O 请求,以读取现有结构或更新(写入)它们。所有写过设备驱动程序(device driver)的人都知道,让设备现表你执行某项操作是一个复杂而详细的过程。它需要深入了解低级别设备接口及其确切的语义。幸运的是,操作系统提供了一种通过系统调用来访问设备的标准和简单的方法。因此,OS 有时被视为标准库(standard library)。
出于性能方面的原因,大多数文件系统首先会延迟
这些写操作一段时间,希望将其批量分组
为较大的组。为了处理写入期间系统崩溃的问题,大多数文件系统都包含某种复杂的写入协议,如日志(journaling)或写时复制(copy-on-write),仔细排序
写入磁盘的操作,以确保如果在写入序列期间发生故障,系统可以在之后恢复到合理的状态。为了使不同的通用操作更高效,文件系统采用了许多不同的数据结构和访问方法,从简单的列表到复杂的 B 树
。
设计目标指的是「开发和设计操作系统时所设定的主要目标和原则」,或者说是操作系统的一些基本设计原则。
最小化
操作系统的开销
(minimize the overhead)。但是虚拟化的设计是为了易于使用,无形之中会增大开销,比如虚拟页的切换,cpu 的调度等等,所以尽可能的保持易用性与性能的平衡至关重要隔离
(isolation)。让进程彼此隔离是保护的关键,因此决定了 OS 必须执行的大部分任务本章节主要用于对操作系统全貌的一个介绍,回答了:“操作系统是什么” 的问题。
我们执行 SQl 的时候,查询的结果是一行行的格式返回的.因此在讲解索引、数据页前我们先来看一下 MySQL 中一行记录是怎么存储的:
在上图中我们可以看到,一条完整的记录分为「记录头」与「真实数据」两部份,下面展开看看记录头的部份:
1. 记录头信息
我们知道 MySQL 支持一些变长的数据类型与NULL值,比如 TEXT、VARCHAR 等等。这些类型的字段存储多少字节的数据是不固定的,所以我们在存储数据的时候把对应信息存储下来方便后续读取。因为该部份对本文影响不大,因此没有展开。
2. next_record 字段
在记录头中这个显眼的「next_record」字段相信已经引起了你的注意,正如其字面意思,该字段表示的是当前记录与一下条记录的偏移量。换一个说法就是,该字段是一个指向下一条记录的指针。
所以我们可以看到,在 InnoDB 中所有的数据就是通过该字段串起来成了一个单链表。而 MySQL 优化手段也是基于此进行改造的。
上图是我经过简化后的示意图,我把一些与本篇无关的内容隐藏掉了,仅保留了比较关键的内容
页是 InnoDB 管理存储空间的基本单位,一个页的大小一般是 16k。数据页中则存放着我们上面提到的一行行记录,现在我们知道记录之间是通过 next_record
字段串联形成一个单向链表。那么在一个数据页中要查找某条记录该怎么办呢?
最笨的办法是从第一条记录开始遍历一遍链表,那么第一条记录我们怎么知道是哪条呢?以及当数据很多时,遍历一遍的时间也是无法忽视的,MySQL是怎么优化的?下面我们来看一下 MySQL 中数据页的格式,如下图:
在上图中我们可以看到一个数据页中关键的几个东西:
n_owned
属性表示该组内共有几条记录比如现在图中有5条记录,InnoDB 会把他们分成2个组,第一组只有一个「最小记录」(也就是头哨兵),第二组则是剩余的4条记录,一共两个组。因此对应着2个槽,每个槽中存放每个组中最大的那条记录在页面中的地址偏移量(即指向最大记录的指针)。每个分组中的记录条数都有规定,规定如下:
所以初始情况下只有最小/大记录,因此页目录(槽)中只有两个组。之后每插入一条记录,都会从页目录上找到对应记录的主键值比准备插入的主键值大,且差值最小(为了让主键顺序排列-从小到大)的槽,然后把该槽的 n_owned
值加一,表示添加了一条记录,直到该组记录中的记录数等于8个。8个后再插入则不满足第二条规则,因此会产生新的一个组,并将原有记录进行迁移,使得满足三条规则。
当前页面中记录太少,不好演示页目录如何加快查找速度的,为此我们往页面添加几条记录,添加后的数据页如下(为了清晰的展示槽与组之间的关系,我将记录之间的指针隐藏了,详细看上一张图片):
因为记录的主键都是从小到大排列的,所以我们可以使用二分法快速查找记录,如图中所示一共有5个槽,接下来我们模拟一下在该表中找到「记录6」的过程:
上面我们已经介绍过,每个组中包含的记录条数最多是8条,所以遍历一个组中的记录代价是很小的。因此我们可以将上面的步骤再提炼一下,总结成在一个数据页中查找元素的步骤:
next_record
属性遍历该槽所在的组中的各个记录至此,我们已经知道了如何在数据页中快速查找到一条记录,而将这一个个数据页连接起来便能是我们存储的所有数据了。那么如何才能高效的在这么多数据页中查找记录,接下来就轮到“索引同志”登场了。
我们知道一个数据页的大小是 16K,而我们业务中一个表的数据动辄就上G,显然一个数据页是无法装下我们的数据的。那么在一个表中的数据页是怎么链接起来的?我们回到查询一条记录的语句上,要在这么多数据页中找到一条记录,最笨的方法便是遍历所有的数据页,那么为什么要遍历所有的数据页呢?我们不能有个聪明点的方法吗?原因是因为数据页之间是没有规律的,我们并不知道搜索调节会匹配哪些页的记录(因为没有规律,所有的数据页都有可能存在),因此不得不遍历所有数据页。
那么如何快速定位到记录在哪些数据页中?我们还记得在数据页里面为了快速定位一条记录,我们为页中的数据划分了组,并且为此建立了一个页目录(槽)。同样的我们也可以想办法为了「快速定位记录所在的页」而建立一个目录,在建立这个目录的过程中需要满足这几个条件(页与页之间的规定):
为此我们得到了一个简易版的索引方案,如下图:
为什么说这是简易版的索引呢?因为我们这么做的前提是假设了目录项是有连续的存储空间的,但实际并非如此,因此会引起下面的几个问题:
为此我们需要一种更灵活的方式来管理目录项,我们不难发现目录项与「真实记录」长的很像,只不过目录项中的两个列是主键与页号而已,为此我们可以复用存储用户记录的数据页来存储目录项,我们把这些用来表示目录项的记录称为目录项记录
那么我们把前面简易版本的索引方案修改后,便能得到下图所示的结构:
以查找记录9为例子,我们来看看是如何通过目录项加快查询速度的:
虽然说目录页中只存放主键值与页号,比用户记录所需要的空间要小很多,但因为一个页只有16K大小,因此能存放的目录项也是有限的。如果表中的数据太多了导致目录页存放不下,那么便会重新分配一个目录页。目录页与目录页之间也通过上述的办法进行复用(利用目录页定位目录页),从而减少单次二分查找的数量,提高效率。在插入很多数据后,整个结构便像下图一样:
上图所示的便是许多文章中提到的 B+树 了,无论是存放用户记录的数据页,还是存放目录项的数据页,我们都把它们存放到B+树这个数据结构中。从上图中我们也可以看出,我们的「真实记录」其实都存放在B+树最底层的节点上(叶子节点)。其余用来存放目录项的节点为非叶子节点或内节点。
我们来简单的计算一下,假设叶子节点所代表的数据页可以存放 100 条用户记录,而内节点可以存放1000条记录,那么:
所以在一般情况下,我们用到的B+树都不会超过3层。这样一来在通过主键去查找某条记录时,最多只需要进行4个页面内的查找(3个目录页与一个真实记录的页),又因为每个页中有页目录(槽),所以在页面内可以通过二分查找快速定位记录。因此我们可以看到这就是使用B+树查询高效的原因。
这种根据主键ID构建的B+树,我们称为「聚簇索引」,这也引申出来了 InnoDB 中所谓的“索引即数据,数据即索引”。
我们可以看到这里只有 ID 作为索引列,但我们平时使用的过程中还可以为其他列建立的索引,因此这就引申出二级索引与联合索引了。
无论是二级索引亦或是联合索引,其实都是基于上面提到的聚簇索引而引申出来的。我们先来看看二级索引与聚簇索引的区别:
二级索引如下图所示:
我们除了可以使用某一列进行索引外,还可以同时为多个列建立索引,而多个列建立的索引则称为「联合索引」。许多文章都会提到最左匹配等概念,而这也是与我们联合索引的结构息息相关的,比如我们想让B+树按照A和B列的大小进行排序,这里面就包含了两层含义:
该索引如下图所示:
在创建和使用索引时应该注意以下事项:
对于 Golang 程序性能分析来说,pprof 一定是一个大杀器般的存在。主要可以分析 CPU、内存的使用情况、阻塞情况、Goroutine 的堆栈信息以及锁争用情况等性能问题。
pprof 是一个性能分析工具,Go 在语言层面就内置了 profile 采样工具。这会涉及到 runtime/pprof
与 net/http/pprof
这两个包。但本文着重于使用 pprof 来分析问题,故不讲解采样相关内容。
以大家优秀的编码水平一般不会写出性能堪忧的程序,所以在此我们使用一个 GitHub 上开源的项目,这个项目预埋了许多炸弹代码。这个性能堪忧的“炸弹”可以有效的帮助我们观测到程序的性能问题。
务必确保你是在个人机器上运行“炸弹”的,能接受机器死机重启的后果(虽然这发生的概率很低)。请你务必不要在危险的边缘试探,比如在线上服务器运行这个程序。
pprof 默认提供命令行的方式来查看各项数据,但命令行的方式显然不够直观。因此我们安装一个图形化的依赖(graphviz)来更直观的展示堆栈信息。
你可以在官网上寻找适合自己操作系统的安装方法,此外在下面这些系统上你可以通过包管理工具来安装它:
1 | brew install graphviz # macos |
该炸弹你可以通过 git
克隆下来,再按照一般的 Go 项目方式运行。为了演示的方便我这使用 go get
的方式展示,注意加上 -d
参数来避免自动安装:
1 | go get -d github.com/FarmerChillax/go-pprof-practice |
保持程序的运行,然后打开浏览器访问 http://127.0.0.1:6060/debug/pprof/
,可以看到如下页面:
页面上展示了程序运行采样数据,分别有:
类型 | 描述 |
---|---|
allocs | 内存分配情况的采样信息 |
blocks | 阻塞操作情况的采样信息 |
cmdline | 显示程序启动命令及参数 |
goroutine | 当前所有协程的堆栈信息 |
heap | 堆上内存使用情况的采样信息 |
mutex | 锁争用情况的采样信息 |
profile | CPU 占用情况的采样信息 |
threadcreate | 系统线程创建情况的采样信息 |
trace | 程序运行跟踪信息 |
由于直接阅读采样信息缺乏直观性,我们需要借助 go tool pprof
命令来排查问题,这个命令是 go 原生自带的,所以不用额外安装。
首先看一下 CPU 的运行情况,打开管理器可以看到此项目在我电脑上占用了 63.3%
的 CPU。
这显然是有问题的,我们使用 go tool pprof
来采集 10 秒 CPU 数据排查下问题:
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
因为我们这里采集的是 profile 类型,因此需要等待一定的时间来对 CPU 做采样。你可以通过查询字符串中 seconds 参数来调节采样时间的长短(单位为秒)
等待一会儿后,会进入一个交互式终端:
输入 top
命令,查看 CPU 占用较高的调用:
参数说明:
类型 | 描述 |
---|---|
flat | 当前函数本身的执行耗时 |
flat% | flat 占 CPU 总时间的比例 |
sum% | 上面每一行的 flat% 总和 |
cum | 当前函数本身加上其周期函数的总耗时 |
cum% | cum 占 CPU 总时间的比例 |
很明显 Eat
方法是造成 CPU 占用过高的原因,输入 list Eat
,查看问题具体在代码的哪一个位置:
从输出结果里可以看到对应的文件为 animal/felidae/tiger/tiger.go
,而且具体的代码行为 24 行的一百亿次 for 循环导致的。
还记得我们一开始安装的 graphviz 图形依赖吗?在安装这个工具后,我们可以通过 web
命令来生成一个可视化的页面:
注意,虽然这个命令叫 web,但它实际上是生成一个
.svg
文件,并调用系统默认打开它。如果你的系统打开.svg
默认不是浏览器,这时候你需要设置下默认使用浏览器打开,或者使用你喜欢的查看方式来查看 svg 文件
上图中,Eat
函数的框特别大,箭头特别粗,就是代表这个函数的 CPU 占用很高(pprof 生怕你不知道.jpg)。到这为止我们已经发现了 cpu 占用过高的原因了,我们修复下这个问题:
经过改造,可以发现CPU的问题已经解决了,但是内存使用还是很高,我们需要继续排查内存问题。
上面我们介绍了命令行与 web 页面两种方式,因为 web 页面可视化的方式排查比较直观,因此命令行排查的方式就不再展开了,输入以下命令可以看到堆内存的占用情况:
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap"
这个命令中 http 选项将会启动一个 web 服务器并自动打开网页。其值为 web 服务器的 endpoint
从上图我们可以看出 Mouse
这个对象的 Steal
方法占用的内存最多,然后我们点击 view
-> source
还可以查看到具体的代码文件及其行数:
采样说明,SAMPLE
菜单中有几个选项,对应说明如下:
类型 | 描述 |
---|---|
alloc_objects | 程序累计申请的对象数 |
alloc_space | 程序累计申请的内存大小 |
inuse_objects | 程序当前持有的对象数 |
inuse_space | 程序当前占用的内存大小 |
从代码中可以看到这么高的内存占用是因为会一直向 m.buffer 里追加长度为 1 MiB 的数组,直到总容量到达 1 GiB 为止,因此我们注释掉相关代码来解决这个问题。
再次编译运行,查看内存占用:
如果你发现程序运行时间长后,内存还是会升高。请不用担心,这是因为后面用于模拟内存泄漏的炸弹
虽然 Go 是带 GC 的,一般不会发生内存泄漏。但凡事都有例外,goroutine
泄露也会导致内存泄露。我们在浏览器 debug 页面能看到此时程序的协程数有 106 条,虽然 106 不多,但对这样一个小程序来说显然是不正常的。
我们仍然以可视化的方式来排查这个问题,输入以下命令查看堆内存占用情况:
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine"
Graph
类型我们在上面已经介绍过了方块与箭头的关系,相信你也一定能理解这里的关系。那么我们这次来看看 Flame Graph
类型,点击 view
-> Flame Graph
:
我们在火焰图中可以看到 wolf.(*Wolf).Drink.func1
这个函数占了总 goroutine 数量的 95%,我们还是像排查内存一样切换的 source
页面,查看代码具体位置:
可以看到,Drink 方法每次会起 10 个协程,每个协程会 sleep 30 秒再推出,而 Drink 函数又被反复的调用,这才导致了大量的协程泄漏。试想一下,如果我们业务中起的协程会永久阻塞,那么泄漏的协程数量便会持续增加,从而导致内存的持续增加,那么迟早会被 OS Kill 掉。我们通过注释掉问题代码,重新运行可以看到协程数量已经降低到个位数的水平了。
如果你跟着本文一步步走下来,到此为止我们可以说已经完成了拆蛋的工作了,这个程序的所有资源占用问题已经解决了。但日常业务中排查问题除了资源占用问题外,还有性能问题。
接下来我们进一步排查性能问题,首先能想到的便是不合理的锁争用的问题,比如加锁时间太长等等。我们重新看一下 debug 页面,虽然协程数量已经大幅度降低,但还显示有一个 mutex
争用问题:
相信看到这里你已经触类旁通了,通过 Graph 查看问题出现的函数,然后通过 source
定位具体的代码。还是和之前一样,打开可视化页面:
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/mutex
可以看到,这个锁由主协程 Lock,并启动子协程去 Unlock,主协程会阻塞在第二次 Lock 这里等待子协程完成任务,但由于子协程足足睡眠了一秒,导致主协程等待这个锁释放足足等了一秒钟。我们对此处代码进行修改即可修复问题。
在程序中除了锁的竞争会导致阻塞外,还有很多逻辑会导致阻塞。我们继续看 debug 页面会发现,这里仍有 2 个阻塞的操作:
阻塞不一定是有问题的,但为了保证程序的性能,我们还是要排查一下。还是上面的那三板斧:
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/block
可以看到这里不同于直接 sleep 一秒,这里是从一个 channel 里读数据时,发生了阻塞。直到这个 channel 在一秒后才有数据读出,因此这里会导致程序阻塞,而不是睡眠。
我们对此代码注释掉,重新编译运行后发现程序还有一个 block。通过排查分析后我们发现是因为程序提供了 HTTP 的 pprof 服务,程序阻塞在对 HTTP 端口的监听上,因此这个阻塞是正常的。
pprof 中有一个 -base
选项,它用于指定基准采样文件,这样可以通过比较两个采样数据,从而查看到指标的变化,例如函数的 CPU 使用时间或内存分配情况。
举个具体的例子,在业务中有一个低频调用的接口存在内存泄漏(OOM),它每被调用一就会泄漏 1MiB 的内存。这个接口每天被调用10次。假设我们给这个服务分配了 100Mi 空余的内存,也就是说这个接口基本上每十天就会挂一次,但当我们排查问题的时候,会发现内存是缓慢增长的。此时如果你仅通过 pprof 采样单个文件来观察,基本上很难会发现泄漏点。
这时候 base
选项就派上用场了,我们可以在服务启动后采集一个基准样本,过几天后再采集一次。通过比对这两个样本增量数据,我们就很容易发现出泄漏点。
同样的,这个炸弹我也已经预埋了这样一个缓慢的泄漏点,但时间我缩短了一下。相信在上面的实操过程中你也发现了端倪,下面我们开始实操一下。
我们运行这个炸弹程序,将启动时的堆内存分配情况保存下来,你可以在 debug 页面点击下载,也可以在终端中执行 curl -o heap-base http://localhost:6060/debug/pprof/heap
来下载。
在资源管理器中我们可以看到程序刚启动的时候,内存占用并不高:
在程序运行了半分钟后,我们可以清楚的发现程序内存开始逐渐增长:
此时我们再执行 curl -o heap-target http://localhost:6060/debug/pprof/heap
获取到当前的采样数据。
在 debug 页面中直接点击 heap 链接会打开一个新的页面,你可以通过删除该页面 URL 中的 debug=1 这个查询字符串来下载数据文件
再获取到两个样本数据后,我们通过 base
选项将 heap-base
作为基准,查看运行的这段时间内哪里内存增长了
go tool pprof -http=:8080 -base heap-base heap-target
显然在这段时间中 mouse
的 Pee
方法增长了 1.20GB,显然这就是内存泄漏的地方,接下来还是通过那三板斧来定位出问题的代码,然后注释修复:
这里主要展示了如果通过 base
参数比对两个 pprof 采样文件,从而提高我们排查问题的效率。
在 Graph 页面你可能会发现有一些绿色框框存在,机智的你肯定也能猜到绿色框框代表的就是减少的使用量
本文主要内容为 pprof 工具的使用,介绍了通过命令行、可视化等方式进行排查。虽然例子比较简单,但是相信通过这些简单的例子可以让你不在畏惧 pprof。
越来越多的故人失去联系,仅能从偶尔的一条朋友圈窥得一丝身影,惊然发现竟恍如隔世,不知不觉间发现自己正一步步的走向荒原、孤冢。愈发感觉到天机命途,不由我定,唯一能做的就是活着,好好的活着而已。
生活生活,先生后活。如此生活,别人与我,自有期望。
我相信,慢慢我什么都会有的。
我会有很多懂我又不懂我的朋友,我会有很多不同姓的家人,我会见到很多风景,也会明白冷暖自知的人间苦楚。我有我的快乐、烦恼,我一步一步地走。
许久之后,我再回望我的青春时光,那样地生活过。他的身影印在这个城市里,我看见了他那时的理想和热血再一刻不停地催促着他向前走去。
少年心气,已经深刻地认识到了我们自身的平庸,却依然在等待着生活中的诸多无意义。我在路上,不知是在企图逃离现实所赋予我的诸多责任,还是尝试寻找自己所信奉的东西。
我们这一代人,如此开放却又狭隘,精神如此丰富又贫瘠,如此乐观又封闭,如此喧嚣却又静寂。我们这群寂寞的人类都在忙着说话、交流、呐喊,然后在现实到来的那一刹那成长。我们眼界开阔,却又因为各种价值观,社会压力中被压得踹不过气来。
]]>本文项目对应地存储库: https://github.com/Farmer-chong/HelloAPIFlask
最近正值毕业阶段,相信许多人的毕业设计都无不例外的选择了Web相关的内容,毕竟这个比较容易实现。我发现许多人都出于从众心理无不例外的使用了Spring全家桶,Spring有着很庞大的生态、久远的历史以及很丰富的社区资料,这使得它被许多从业者使用。但也正是这些优势导致它对新手或者快速上手开发变得十分不好,再加之国内的毒瘤技术社区等原因,使得一个小问题往往需要花费大量的时间来排除错误的答案。
相比于繁重、学习成本较高的Spring,Flask其简单的语法可以是我们快速的上手完成(应对)毕设这一类学业任务, 因此本文仅停留在 能用 阶段, 更多底层原理需要读者自行了解(这里推荐一下李大的狗书《Flask Web 开发实战 》) 另外出于为社区做贡献等情怀原因也是写这一系列的初衷。
本文默认使用Windows进行开发,Linux/macOS会特别说明
相比于传统的后端渲染模板架构,本文采用目前主流的前后端分离结构
对于前后端分离,服务端只需要提供API接口来处理请求。简单的来说就是前端不再局限于浏览器,还可以是app、小程序亦或者是爬虫程序。对后端而言,我们不再渲染html返回给前端,而是与前端共同约定一个数据格式进行交互。
如果读者的思想还停留在具体的浏览器中,那需要读者打开思维的局限来理解。无论是传统的后端渲染还是前后端分离,其本质都是操作 HTTP报文 。
许多人此时会有个疑问:为什么一会儿Flask一会APIFlask的,这两者有什么关系?
先来简单的说一下,Flask
是一个十分流行的 Python Web 框架。而APIFlask
则是基于Flask
框架针对 Web API开发的特点二次开发的框架,这是一个针对API而生的框架(或者具体点前后端分离)。如果读者有Java
开发经验,可以理解类似于Spring
与Spring Boot
的关系。
扩展阅读:
因为APIFlask是一个Flask的扩展补充框架,因此它大部分都是与Flask保持一致的,你甚至可以把APIFlask当成Flask来写(当然这并不推荐)。因此对Flask有经验的读者许多内容是可以跳过阅读的,我甚至更推荐你去阅读 APIFlask的官方文档。
废话放在后面说,作为快速上手教程,理所应当地先来个Hello World感受一下Flask
的魅力,作为一篇保姆级教程下面就让从pip
这个包管理器开始安装一下APIFlask
吧(默认安装好python及其包管理器)
小白的话就直接运行下面这条命令即可
1 | pip install apiflask # Linux环境用pip3 |
当然本人还是强烈建议使用虚拟环境哈。细节就不多赘述了,下面开始写一个Hello World吧:
1 | from apiflask import APIFlask |
哪怕你没有开发经验,单纯的看单词也许已经猜到它是做什么了,下面就来一步步的分解这个程序。
我们使用pip
安装apiflask,其实也就是下载了一个叫apiflask的包。因此我们可以通过apiflask包的构造文件导入开发的类和函数。我们一开始从apiflask包导入APIFlask类,实例化这个类,就得到了我们的程序实例app
:
1 | from apiflask import APIFlask |
传入apiflask的第一个参数是模块或者包名词,这里使用Python的特殊变量__name__
。
首先对于很多初学者来说都会有这么一个问题:路由是什么?
回答这个问题之前我们先来看看一个传统的Web应用,客户端和服务器上的程序是如何交互的:
而我们现在要做前后端分离,整个流程和上面是一致的,但客户端具体的对象则从浏览器变成了一段发起 http 请求的代码,以网页前后端分离为例稍微完善一下如下:
⚠⚠注意⚠⚠ 这里的 AJAX 是指 Asynchronous JavaScript + XML(异步JavaScript和XML) 这是一个技术名词,读者需要注意与较多人熟知的
jQuery
库的ajax
方法进行区分!!!
简单的来讲,jQuery
的ajax
是 AJAX 技术的一个实现,有着许多同类的替代品,比如axios
。由于笔者见过太多的人将两者混淆,故在此着重强调。
这些步骤中,大部分都是由APIFlask完成,我们只需要将处理函数与URL对应起来。只需在函数添加app.route()
装饰器,并传入URL规则作为参数,就可以让URL与函数建立关系。这个过程我们称为注册路由(route),路由复制管理URL和函数之间的映射,而这个函数则被称为视图函数(view function)。
一个视图函数可以绑定多个URL,具体就像下面这段代码:
1 |
|
我们还可以在URL规则中添加变量部分, 使用<变量名>
的形式表示。APIFlask处理请求时会把变量传入视图函数,代码如下:
1 |
|
如果URL变量中包含变量,但如果又要适配没有变量的情况,比如与多个URL配合起来使用,那么我们可以给一个默认值:
1 |
|
Flask内置了一个开发服务器(由依赖包Werkzeug)提供,足够在开发和测试阶段使用,而APIFlask是基于Flask二次开发的,因此我们一样可以使用。
在生产环境需要使用性能够好的生产服务器,以提升安全和性能,具体更多内容移步到《Flask Web 开发实战 》
在项目的根目录下,输入下面这条命令:
1 | flask run |
然后我们会看到如下输出:
1 | * Tip: There are .env or .flaskenv files present. Do "pip install python-dotenv" to use them. |
这时候我们在浏览器访问这个URL,会看到这样显示:
同理,其他URL自行替换即可得到相应的结果。但由于我们开发的是前后端分离架构,后端只负责提供API进行访问,除了基本的GET
请求外还有大量其他请求报文。因为浏览器的调试无疑是不够的,为此APIFlask
提供了交互式API文档(基于Swagger UI and Redoc)
要进入这个交互式API文档,默认的方式是访问/docs
路由,为此我们重新在浏览器访问: http://127.0.0.1:5000/docs,即可看到下面的这个页面:
后续都会基于该API文档进行演示,当然你也可以访问: http://127.0.0.1:5000/redoc 查看基于Redoc生成的API文档。
需要注意,网上许多教程都是通过
app.run()
形式来运行开发服务器的,这种方式其实是旧的启动方式,已经过时了目前已不推荐使用(deprecated)。
Flask通过依赖包Click内置了一个CLI(Command Line Interface, 命令行交互界面)系统。当我们安装Flask后,会自动添加一个flask命令脚本,我们可以通过flask命令执行内置命令、扩展插件的命令与我们自定义的命令。其中,flask run
命令用来启动开发服务器。
此外,你还可以执行flask --help
查看所有命令,这些命令都需要在项目的根目录执行。
上面启动开发服务器我们使用了flask run
来启动,这就不禁让人好奇,Flask是如何找到程序的?
其实这个问题是因为Flask会自动探测程序实例,一般来说,在执行flask run
命令运行程序前,我们需要提供程序实例所在模块的位置,而自动探测是按照下面这些规则:
app.py
和wsgi.py
模块,并从中寻找名为app
或application
的程序实例app
或application
的程序实例因为我们上面的程序代码文件名为app.py
,所以flask run
命令会自动在其中寻找应用实例。如果你的程序文件名是其他名称,比如hello_apiflask.py
,那么需要设置环境变量FLASK_APP,将包含程序实例的模块名赋值给这个变量。命令如下:
1 | set FLASK_APP=hello_apiflask |
Linux或macOS系统使用export
命令:
1 | export FLASK_APP=hello_apiflask |
FLASK的自动发现程序实例机制还有第三条规则: 如果安装了python-dotenv,那么在使用flask run
或其他命令时会自动使用它从.flaskenv
文件和.env
文件中加载环境变量。
安装python-dotenv包命令如下:
1 | pip install python-dotenv # Linux环境用pip3 |
除了管理自动发现程序实例外,我们还可以用来管理程序需要的环境变量。我们在项目根目录下分别创建两个文件:.env
和.flaskenv
,其中.flaskenv
用来管理和Flask相关的公开环境变量。而.env
文件则用来管理包含敏感信息的环境变量。
.env
包含敏感信息,除非是私有项目,否则绝对不能提交到Git仓库中
为什么写扩展阅读,扩展阅读的作用?
扩展阅读模块主要是对上面的内容进行补充,内容并非必读内容并不会影响程序的运行,更多是补充开发相关各方面的知识内容。
开发环境(development environment)和生产环境(production environment)。根据运行环境的不同,Flask程序、扩展以及其他程序会改变相应的行为和设置。为了区分运行环境,Flask提供了一个FLASK_ENV环境变量用来设置环境,默认为production
(生产)。在开发时我们可以将其设置为development
,这会开启所有支持开发的特性,为了方便管理我们还可以将其写入到.flaskenv
文件中:
1 | FLASK_ENV=development |
现在启动程序,你会看到下面的输出提示:
1 | (env) E:\Github\HelloAPIFlask\demos\hello>flask run |
在开发环境下,调试模式(Debug Mode)将被开启,这时执行flask run
启动程序会自动激活Werkzeug内置的调试器(debugger)和重载器(reloader),他们会为开发带来很大的帮助。
如果你想单独控制调试模式开关,可以通过FLASK_DEBUG环境变量来设置,设为1为开启,设为0则关闭,不过通常不推荐手动设置这个值
在 Python 中,虚拟环境(virtual enviroment)就是隔离的 Python 解释器环境。通过创建虚拟环境,你可以拥有一个独立的 Python 解释器环境。这样做的好处是可以为每一个项目创建独立的 Python 解释器环境,因为不同的项目经常会依赖不同版本的库或Python版本。使用虚拟环境可以保持全局 Python 解释器环境的干净,避免包和版本的混乱,并且可以方便地区分和记录每个项目的依赖,以便在新环境下复现依赖环境。
虚拟环境通常使用Virtualenv来创建,除此之外还有Pipenv、PDM等诸多环境管理工具。简单起见,我们使用Python3自带的工具来创建虚拟环境,首先确保我们当前工作目录在项目的根目录,然后使用python3 -m venv env
命令为当前项目创建虚拟环境:
1 | python -m venv env |
1 | python3 -m venv env |
提示: Windows用户可直接复制,但Linux系统大多存在python2与python3,Linux用户需要注意这点
这会在当前目录创建一个名为env的文件夹(命令最后一个选项为文件夹名),其中包含隔离的Python解释器环境。
在创建虚拟环境后,我们还需要载入(激活)这个环境,接下来使用.\env\Script\active
来激活虚拟环境:
1 | E:\Github\HelloAPIFlask>.\env\Scripts\activate |
Linux环境使用source
命令来激活:
1 | farmer@farmer-ubuntu:~/HelloAPIFlask$ source ./env/bin/activate |
可以看到虚拟环境激活后命令行前面多了一个虚拟环境的标识,这就表示虚拟环境激活啦!使用pip list
我们可以看到该环境是一个全新的环境。
--host
选项将主机地址改为0.0.0.0
使其对外部可见:1 | flask run --host=0.0.0.0 |
需要注意这里的外部是指你计算机外部,并不是指公网。一般个人的电脑是没有公网IP(公有地址),所以此时你的程序只能被局域网内的其他用户通过你电脑的IP(内网)进行访问。
Flask提供的Web服务器默认监听5000端口,你可以通过--port
选项进行更改:
1 | flask run --port=2333 |
此时会监听来自2333端口的请求,对应的程序网址也变成了 http://127.0.0.1:2333/。
这一切开始于2010年4月1日,Armin Ronacher在网上发布了一篇关于”下一代 Python 微框架”的介绍文章,文章里称这个 Denied 框架不依赖 Python 标准库,只需要复制一份
deny.py
放到你的项目文件夹就可以开始编程。随着一本正经的介绍、名人推荐语、示例代码和演示视频,这个”虚假“的项目让不少人信以为真。5天后 Flask 就从这么一个愚人节玩笑诞生了。
同样的,APIFlask这个框架开始于 2021年4月1日,不知道是机缘巧合还是Grey Li有意为之的一个小玩笑,APIFlask与Flask均诞生于4月1日这一天。🎉🚀✨
在与Grey Li求证后得知两者选择同一天发布是有意为之的!!😄
]]>Rust
,这是一门系统级语言。保证安全的同时摆脱了GC
, 它很香同时也很难上手, 刚学完一点皮毛知识决定做个小玩意儿玩玩,是骡子是马总要拉出来溜溜~目前个人认为Rust
在性能上是可以和C++
媲美的一门语言,既然如此那就用它来为Python
加个速吧!😊
为Python
加速与写C语言
扩展类似,最终通过pyd
来调用。在此之前我们用到Rust
的pyo3
库,另外我们编写的是一个lib
而不是应用程序,因此我们要创建lib
项目。
创建lib
库项目:
$ cargo new <project name> --lib
在 Cargo.toml 文件添加pyo3
依赖:
1 | [lib] |
使用文档的例子,编写一个Hello World
试试,代码如下:
1 | use pyo3::prelude::*; |
然后用打包命令将这个项目build一下:$ cargo build --release
这时候应该能看到项目的结构如下:
1 | . |
这里的string_sum.dll就是我们需要的,我们将文件的扩展名改成pyd
即可得到我们的python
扩展文件 -> string_sum.pyd
完成之后尝试一下调用这个文件,新建一个test
文件夹并将其复制进去, 最后调用一下:
1 | import string_sum |
继续在刚刚的文件中添加一个需要大量计算的函数:
1 | ... |
后续的步骤与上面一致,编译重命名后拷贝到python
目录下,修改一下刚才的python
代码:
1 | import string_sum |
运行看一下结果,从结果看到Rust
比Python
足足快了125倍多!!!
如果不读研,校招可以说每个人一生只有一次,当你毕业了也就失去应届生这个身份了也就不能参与校招了。因此校招这个机会是十分重要且难得的,一定要尽早准备参加,重视重视再重视!!!⚠️
切记 春招找实习,秋招拿offer
虽然应届生身份法规上是两年内,但又有几个公司这么看尼~
首先说一个思想上的几个误区:
据我观察我们学校很多人都有这个误区,但其实并非这样。校招可以说当你踏入大三,就要开始准备了!另外对于面试官来说,应届生只有CS专业与非CS专业的两种情况,你前端还是后端只是决定了考察的重点或者问题方向罢了。下面就来详细的展开说说校招的这些事吧~
开始之前先介绍一下个人情况吧,看官还请结合自身实际参考阅读:
Python
、JavaScript
、golang
这三门语言为我的主技术栈, 当然别的语言也会点。Flask
、APIflask
、gin
、Vue
等常见的web开发框架我都能熟练的使用在编程方面,你应该有以下的基本素养:
上图可以说是大学生应该知道的招聘流程,我以一个刚步入大三的学生身份来remake整个流程。是的,大三开学就需要开始准备校招事宜了!
在学期末的时候开始投递简历,应聘大厂的实习岗位。实习可以说是我们三本学生进入大厂为数不多的机会了,切记要把握好!
常言道:春招找实习、秋招拿offer。这个学期开学一般都是3月份了,这时候你应该已经在寒假就完成简历的投递了。这个学期的主要任务就是:
每年的提前批时间都不一样,需要自行上牛客网浏览相关资讯。关注大厂提前批的动态。
这个暑假一般而言是在秋招应聘的实习公司与面试中度过的,争取拿到正式offer是这个时间段唯一目标!有实习经历与没有实习经历是有很大差距的!具体原因下文会讲到。
大四开学就是9月了,也是正式的校招与秋招的开始。是厮杀的最火热的时间段。
在这个时间段往往会遇到卡简历、卡学历等等的不公平待遇,这也是为什么说提前批争取上岸的原因。
这个时间段的笔试,除非你能答到基本满分,否则你可能连面试官都见不到。
至于笔试题的难度,按ACM标准来看就好了。至于秋招结束还没上岸或者拿不到保底,那只能希望来年的春招了。
简历是十分重要的,最重要的是一个“真”字。内容一定要真实会就会,不会就不会。其次是“精”,切忌杂七杂八的东西往上丢,比如项目部分写学校的课程实训就很掉分。
最后需要注意里面的内容需要突出重点而不是一股脑的全丢上面。尽可能控制在一页内,主要包含你会的技术栈、能拿得出手的项目、经历与技能证书、自我评价这几个部分,下面详细展开来说。
在技术栈上,经可能的突出与求职岗位所匹配的技术。另外需要注意描述技能掌握程度时的词语了解,熟悉和精通的区别。
“了解”指对某项技术只是全面学习过或看过书,但并没有做过实际的项目。一般不建议在简历中写只是肤浅地了解一点的技能,如:只是在我校Java课程中学过java,那就只能算了解。
在简历描述中的掌握程度大部分应该是“熟悉”,一般毕业生是使用不到“精通”的。“熟悉”意味着你对这门技术有着深入的使用且已经有较长的时间,通过查阅相关文档可以独立解决大部分问题,那么我们就能熟悉它了。
⚠️需要注意,学习的课程实训并不能算数!举几个例子,熟悉 MySQL 你应该能清楚的知道事务隔离级别、sql调优、处理过数据库事物并发带来的相关问题等许多细节;熟悉 Python 你应该知道GIL(全局解释锁s)、元编程、魔法方法(magic function)、迭代器生成器装饰器(iterator, generator, decorator)等等(如果你刚好也是Python技术栈,那我推荐你看一下《流畅的Python》这本书);最后再以人数最多Java来举例子,Java你应该知道JVM内存区域布局、基本的垃圾回收机制和原理、各种集合类的底层原理、各种InputStream/OutputStream
的区别,特别是HashMap中的桶结构的进化与退化以及接口,抽象类区别,应用场景。
还有一些计算机通用的底层原理(八股文)就不过多赘述了。
项目与经历最好就是写你实习负责的项目与实习经历⚠️切忌将学校课程实训的项目也往上写⚠️因为这类项目根本无法吸引面试官眼球!甚至让面试官感觉你技术就那样儿~ 因此宁可只有一个也不写多,宁愿刚八股文也不要让面试官对你没兴趣!
项目这部分是简历中最重要的,因为它直接关系到面试官与你谈的内容。以我这几次面试来看面试官对你的项目问的越多,了解的越深入越感兴趣你就越有戏,我有几次甚至因为和面试官聊项目聊到timeout了😂面试官也对这个项目给出了好多有用有意思的建议!如果项目面试官不感兴趣的话,那就只能问你八股文了,这样除了要有扎实的基本功外还要看你能否答到面试官想听的点了,这难度就可想而知了~ 总的来说你的项目就是要让面试官感兴趣,因此项目部分提炼出来的要点如下:
自我评价想不到说啥,就来说说技能证书吧~ 技能证书其实也是按照精而不多原则来填写(这不是废话),当然真实的情况往往是没有几个能拿得出手的证书😄 这时候就有要按公司、按职位来写了。
以英语证书为例,如果你投的岗位与公司对英语水平没有太大要求而你又只有四级证书,那最好还是不要填写四级证书为好。因为在整个应届生群体中雅思托福的不在少数,你的简历上的四级反而是一个掉分项!
在复习上面的内容时,切记不要按学校教的来复习! 因为学校教的不够深入,前三部分建议参考考研408的真题。按考研的难度来复习就对了。八股文与考研这些东西,网上资料十分详细(特别是GitHub上面)这里就不展开细说了,列几个我认为秋招一定要会的基本功吧:(后面想起来会动态补充)
这部分按考研标准来就好了,几种常见排序、数据结构是一定要会的~ 这里推荐《小灰算法》这本书,里面所讲的都是面试的基础。
这一part本人不熟,以 MySQL 与 Redis 为主:
过去的已无法改变,总结一下过去四年发生的点滴无论好坏,也叫作给自己一个交待吧。毕竟我们不也天天code-review么
不知不觉四年就过去了,最近经历了秋招见识到了找工作的艰辛,也有收获到了offer的喜悦,同时也有着选offer的纠结。找工作的艰辛源于自身实力不足,收获的offer是对我四年努力的肯定,选offer的纠结是出于对自身现状的不满。Anyway, dont stop learning.
回顾过去,一个小白从网络入坑计算机到逐步转型web狗🐕再到现在慢慢靠近云原生、微服务相关领域。经历的点点滴滴,让人不禁感叹如果能remake该多好😶。
如果将大学四年放到整个计算机生涯中,那我会比作从婴儿蹒跚学步到一个学徒入门的水平。还记得刚入学的时候怀揣着一颗敬畏的心去面试部门,希望在这里能找到志同道合的人。现在看来这个决定是十分正确的。还记得大一下学期的时候,忐忑的去面试当时垄断校园公众号的小喵团队。二面时那“艰巨”的任务让许多人望而却步,庆幸的是我没有放弃。
虽然这一个星期我活得很狼狈,但也正是这一个星期让我正式的踏上了开发这条路。大一如果说是跌跌撞撞的入门,那么大二可以说是渐入佳境、步入了快车道了。
大二一年可以说是完成了web前后端的一个蜕变。从与同学打闹的一个爬虫小程序到为了偷懒不跑i广科
而写的一个定时爬虫小jio本,从flask框架
与Jinja2
模板的传统web开发到flask
与Vue
的前后端分离,以及不知何时学会的JavaScript
。
大三是一个比较卷的一段时间,从手撸http协议到手撸玩具分布式再到前端Vue的一些底层api、原理的学习。也是从大三这个阶段,开始有了考研的想法,虽然这个想法动摇的十分厉害,就像大海上的小舟一样随波漂流。经过思考后我决定两个都要(小孩子才做选择)🤣不过此时的我还纯纯的不知道春招、秋招、提前批这些东西是啥,回过头来看真的挺呆的。
到了大四了,无论是不是想找工作。在周围人的影响下我了解到了秋招、春招等信息,一开始还不是很上心,毕竟学校还有课程,考研408也还没看多少,八股文就更不用说了比408有过之而无不及。
大四是一个毕业的季节,同时也是考研的冲刺阶段。无论如何我都应该做出一个抉择了,要么考研要么秋招。对于一个末流三本的菜狗来说无论哪一条路都不好走,对于一个选择困难症晚期患者来说这是十分折磨人的。在一众吃瓜群友的吐苦水后,我选择了秋招这条路,现在看来这个决定还不错。秋招拿到了5份offer,选offer也不是件省心的事儿。因为无论是北上广深于我而言都是离家,别人也给不了太多的意见终归还是要自己做决定。从公司的待遇与职位的匹配度到所在城市的风俗习惯再到相关的入户政策等等诸多细枝末节的考虑,真让人头昏脑胀。
秋招也结束了,就等三方的流程走完就尘埃落定。虽然明年春招或许会再尝试,不过躺平又未尝不是一种选择?
如果让我现在回到大一,我有什么是比较后悔的?我会怎么重新选择学习路线?我又会怎么弥补这些遗憾?
后悔事值得总结的大概就两件吧。其一是没有好好准备求职,比如没有参与春招实习、秋招导致错过了求职的黄金时间。另一件则是知乎上的热门话题了:为什么大学生都喜欢翘课,大一大二的公共课浪费了许许多多的精力与时间。如果给我现在回到大一,我会勇敢的翘了。如果你问我学校的课程有没有用?答案是不太有用。真实的情况往往是学校教学的内容早已掌握甚至比老师还会,这不是狂而是三本无奈的现状。教学的老师往往是开学前的一个星期临时自学然后就参与教学活动中来,上课讲错基础概念更是常有的事,这就是我为什么说上课浪费了我大量的精力与时间。第二个原因则是因为我已掌握的知识能反哺课程。以web开发为例,不外乎就是操作http协议
与数据库的CRUD
。熟悉了这个其实换框架只需几个小时,或者通过代码提示甚至可以直接上手完成实训、考试。往往裸考的成绩都能排到全班前三,真是讽刺啊~
骂骂咧咧的写了篇流水账,过段时间再看回来或许会直接社死了吧。 Anyway, lifelong learning. 学习永远不晚,别停下学习的步伐就好啦。
很多人都问过我3+1是否能去,废话不多说结论放在前头:
当然事无绝对,觉得合适就好~
]]>Nginx
了,通过资料搜集,大致确定了基本的流程,因此有了这么的这个流程图:由于非官方的代理,因此我们无法知道教务系统部署在内网的哪些机器上,而校园网一般使用B类网,因此需要扫描大量的ip地址。
但这样无疑会触发学校网管的报警,正常情况下会导致ip短时间内被封。导致扫描质量低(由于被禁网,导致程序误认为扫描超时从而导致目标未被发现)。
基于这个问题,随之而生的想法就是分布式扫描,将扫描的工作打散到用户中,服务器只负责扫描几个主干网段。
一句话概括就是: 将扫描工作分散到多台机器上,流程图如下:
前情提示: 本文通信均采用HTTP、代码部分存在伪代码
代码仓库: (暂不开源)
consul
发送负载信息大致结构如下:
作为一个分布式系统,不可避免地需要服务注册与服务发现。因此需要一个注册中心
来处理各个服务
之间的依赖关系,在服务上线后通知依赖这个服务的服务(这里有点绕)
举个例子:数据库
的每个操作都需要记录日志,日志为了统一管理所以有一个日志服务
专门处理日志信息。此时,日志服务
因某些原因(可能是人为、也可能是网络掉线等)在数据库服务
注册之后才注册,这是注册中心
就需要通知数据库
,让数据库
的日志记录转成使用日志服务
。
作为一个注册中心,首先我们需要一个web服务来接收服务发送的信息(注册、依赖更新、注销等等),但在这之前我们先来定义一下我们要用到的结构(在面向对象中为类)
registry
来表示注册操作registrations
来存放注册的服务add
注册服务notify
事件通知sendRequiredServices
发送依赖的服务sendPatch
发送依赖项remove
移除(注销)服务Heartbeat
心跳包Registration
表示服务注册结构体ServiceName
服务名ServiceURL
服务地址RequiredServices
[数组]服务依赖项ServiceUpdateURL
服务与注册中心沟通的URLHeartbeatURL
心跳检测地址1 | type registry struct { |
既然注册中心是作为一个web服务实现的,那么肯定是需要一个web server
的,由于项目属于玩票性质,也不算大因此使用Go内置的net/http
来实现,代码如下:
1 | type RegistryService struct{} |
对服务来说,就是根据注册中心定好的规则来注册服务,然后根据自身的依赖来处理对应的功能。
因为要处理相应的依赖,因此除了Registration
外,再定义一个处理服务依赖的结构体及方法: providers
.
1 | type providers struct { |
因为每个服务都需要使用注册这些通用的功能,且这部分的工作都是重复的,因此将web抽出来公用.
1 | func Start(ctx context.Context, host, port string, |
至此,可以开始专心的写业务了
得益于Go高并发的优势,开启数百万个的goroutine
的开销也不会很大,非常的轻量!🛫
因此代码实现起来很轻松,大体思路和端口扫描器类似,在此基础上根据目标特征添加判断条件即可
端口扫描器代码如下:
1 | ... |
测试器主要功能是从数据库中取出教务系统地址,然后测试。与扫描器不同仅在于扫描器是写,测试器是读。因此这部分内容和扫描器实际上是在同个包内的,只是逻辑上将它分离了出来。
这部分其实和consul
的功能是重复的,因此代码不过多赘述
由于校园网中,教务系统的地址不会太多,因此数据库的选择十分的随意(不存在性能方面的要求),所以这里使用自己熟悉的redis
作为数据库。
由于本系统是和官方的负载均衡并行的 因此存在某些结点用于两者共同访问导致压力上涨,响应不及时,因此利用redis
的sorted-set
在测试的时候,将响应快的地址设置高分数,使用sorted-sets
的好处还有一个就是,集合的元素都是不重复的!,对于代理池来说,这个分数代表着教务系统地址稳定性的重要标准,因此设置分数的规则如下:
由于是内网环境,分数与超时时间可以根据实际情况设置更严格
主要功能就是简单的CRUD啦,本文只讲逻辑与伪代码,实现部分就不多说了。
1 | // set jwglxt to max score |
该部分可以说是系统实现的关键了,因为nginx
自带的负载均衡是写死的,不能根据后端情况动态调整,通过一番搜索对比,最终决定了consul
+ upsync
方案。
upsync
一个Nginx的模块(扩展)consul
一个分布式高可用的系统这部分仅限于“能用”阶段,笔者也不太懂,就不乱说啦!
主要就是Api服务定时的获取数据库内容(分数作为权重),然后推送到consul中
该项目是学习Go时的一个练手项目,很多地方都不太好,因此仓库就不开源了🐕
]]>之前曾在GitHub上看到过本校师兄开发的school-api
–一个基于旧版正方的python SDK,但新版无法使用。因此花了两天时间研究了下新版正方的登录(能登录后续的就EZ啦~)
既然都弄了,因此计划开发一个新的SDK。我比较懒 暂命名为new-school-sdk
,项目目前还在开发中,先将登录的流程、验证码识别的思路罗列出来。(拿到了cookies 还有啥不能干嘛)
项目Github地址: https://github.com/Farmer-chong/new-school-sdk
通过观察发现,有以下几个难点:
cookie
即能完成登录。针对上述问题,开始一一解决
前置工作准备好后,开始从服务器获取验证码并进行验证
网络抓包发现,验证码是异步获取的,每次刷新都会发送一个请求到/zfcaptchaLogin
请求报文内容有:
1 | "type": "refresh" |
响应报文内容:
1 | imtk: "29730cb5-d7ff-4fc9-aa9d-e3efc0a07f55" |
观察请求报文发现需要type
、rtk
、time
和instanceId
这几个字段。
其中rtk
未知,因此开始寻找其出现的地方。通过查找发现rtk出现在一个js文件中,初步猜测rtk
是一个令牌,由服务器随机生成的。
因此我们要先获取rtk
令牌,然后利用正则表达式将其值提取出来。
但现在仍然无法获取验证图片的原始数据,再观察img
的src
属性,得知响应报文中的mi
和si
分别别是验证码
和滑块
。并且需要的url参数我们也已经获取了。
向/zfcaptchaLogin
发送一个GET
请求,请求参数如下:
1 | type: image |
上文中有提到,参考这篇文章: https://blog.dairoot.cn/2021/06/26/zf-sliding-captcha/
大致流程如下:
因此即可计算出该线的x轴坐标,因此得到滑块偏移量。
从上一步中,我们得到了偏移量X
和Y
,接下来就要开始模拟人手拖动滑块的过程了。人手滑动验证码时,一般都是先快后慢的一个速度曲线,因此利用物理学公式分段设置加速度a
,前半段a > 0
,后半段a < 0
。
当前速度用v
表示,初速度用v0
,位移用x
,时间用t
,它们之间满足如下关系:x = v0 * t + 0.5 * a * t^2
v = v0 + a * t
移动算法的代码实现如下:
1 | def _get_track(self, distance, y): |
至此,我们得到了发起请求的所有数据,因此向/zfcaptchaLogin
发送一个POST
请求,请求体如下:
1 | type: verify |
当验证通过时,得到如下的响应体:
1 | msg: "" |
通过查看页面源码和点击登录后抓包,登录发送一个请求到/xtgl/login_slogin.html
,然后返回一个302的跳转。
其中请求报文内容如下:
1 | csrftoken: csrftoken |
此处csrftoken
和mm
两个字段是未知的。其中csrf
令牌是为了防止攻击的,一般包含在form
表单中,由后端生成。因此我们可以直接从页面中提取。如下图:
而mm
字段,通过对前端异步请求部分的代码进行分析后,发现是利用RSA
进行加密,从抓包中可以发现一个发送到/login_getPublicKey.html
地址的GET
请求。其响应体内容如下:
1 | exponent: "AQAB" |
因此得到了RSA
的指数
和模
,但这里的modulus
长度为 172。大概率是正方修改过加密,在JavaScript
文件的注释中也可以看到。
本来是打算自己重写一个python版的实现,后来在GitHub
上发现已有前人栽树,我乘凉就好啦!
到现在为止,整个登录流程的未知项就全解决了!🛫🍯
再次观察数据包的流程,得知登录各项的顺序并做优化:
! 注意,在登录发生302跳转的时候,cookie会发生改变 !
csrf
和原始的cookies
rsa
公钥cookie
成功截图:
sdk开发中,希望大佬们多多给意见或者一起开发哈!
]]>数组是切片和映射的基础数据结构,因此了解数组的工作原理有助于理解切片和映射。
和C语言一样,在go中数组也是一段连续、长度固定用于存储同一类型元素的连续块。
数组的声明和初始化,和其他类型差不多。声明的原则是:
1 | var array [5]int |
声明变量时,总会使用对应类型的灵芝累对变量进行初始化,如上面的代码声明了一个数组array
,但我们还没有对他进行初始化,此时这个数组内的值,就是对应类型的零值=> 这里的对应类型时int
,因此改数组目前为5个0 [0,0,0,0,0]
由于数组初始化后长度是固定的,如果需要存储更多的元素则需要进行扩容。也就是需要再创建一个更长的数组,再把原来的数组复制到新数组里面。
上面的数组仅仅只是声明,go还可以很方便的初始化并声明:
1 | array := [5]int{1, 2, 3, 4, 5} |
上面的这段代码相当于下面:
1 | var array [5]int |
除此之外,go语言还能自动计算声明数组的长度,也就是根据内容,自动分配长度
1 | array := [...]int{1,2,3,4,5,6} |
有的时候我们已知数组的长度,但内容只知道个别几个,我们可以用下面这种方式:
1 | array := [5]int{1: 10, 3: 30} |
这样我们声明了一个长度为5
的数组,并且初始化索引为1
和3
的元素
数组使用上和别的语言没有太大的差异,主要就是通过下标访问。值得关心的是,Go语言的指针数组十分的好用
将一个指针数组赋值给另一个:
1 | var arr1 [3]*string |
此时复制后的两个数组则指向同一组字符串了。
在函数间传递变量时,总是以值的方式传递(也就是值传递)。因此在函数间传递数组是一个开销很大的操作–比如有个占用8M
内存的数组,那么每次调用这个函数的时候go都会在栈上分配8MB的内存,试想一下同时调用100次这个函数,占用的内存会多么的惊人。
虽然Go自己会处理复制的这个操作,但还有一种更优雅的方法来处理这个操作,这个方法在C中十分的常见->传入指向数组的指针
1 | // 分配一个8MB的数组 |
这是传递数组的指针的例子,会发现数组被修改了。所以这种情况虽然节省了复制的内存,但是要谨慎使用,因为一不小心,就会修改原数组,导致不必要的问题。
这里注意,数组的指针和指针数组是两个概念,数组的指针是
*[5]int
,指针数组是[5]*int
,注意*
的位置。
针对函数间传递数组的问题,比如复制问题,比如大小僵化问题,都有更好的解决办法,这个就是切片,它更灵活。
切片是一种数组结构,它是围绕动态数组的概念构建的(⚠和python的切片不完全相同)。切片可以按需自动增长和缩小,因为切片底层内存也是在连续的块中分配的,所以切片还有索引、迭代以及垃圾回收等好处
切片的底层是数组,切片本身非常的小,它是对底层数组进行了抽象。切片有3个字段的数据结构,包含了Go需要操作数组的元数据。
这三个字段分别是指向底层数组的指针
、长度(切片能访问元素的个数)
和切片总体的容量(真实容量)
为了解决数组长度不可变,切片实际上就是提前声明了一个更长的数组(即切片的容量),而切片的长度表示当前切片内能访问的元素的数量。
因此切片有这样一条公式:长度<=容量
1. make和切片字面量
使用make
函数时,需要传入一个参数,指定切片的长度
1 | // 创建一个长度和容量都是5的字符串切片 |
前面说到,切片的长度和容量是两个不一样的概念,因此创建的时候也可以指定长度
和容量
1 | // 长度为3,容量为5 |
除了使用make
函数,我们还可以使用切片字面量来声明切片–指定初始化的值
1 | slice := []int{1,2,3,4,5} |
可以发现切片和创建数组非常像,只不过不用指定[]
中的值。 注意此时切片的长度和容量是相等的,并且会根据我们指定额字面量推到出来,当然我们也可以只初始化某个索引的值:
1 | slice := []int{2: 1} |
2. 空切片和nil切片
有的时候我们需要声明一个值为nil
的切片(nil切片)。只要在声明式不做初始化就可以了。
1 | var slice []int |
空切片和nil切片不同的地方在于,空切片的底层数组包含0个元素,也就是说没有分配任何存储空间。
但切片里面的指向底层数组的指针是有内容的,而nil切片指向底层数组的指针则为nil
1 | slice := make([]int, 0) |
3. 使用切片
go的切片用法上和python的类似,如下:
1 | slice := []int{1,2,3,4,5} |
需要注意,第一个切片因为使用字面量的方式,因此它的长度和容量都为5。不过之后的newSlice
就不一样了,对于newSlice
来说其底层数组的容量只有4个元素,切片长度为2。根据下面的公式,可以计算任意切片的长度和容量:
1 | 对于底层数组容量为K的切片 slice[i:j] |
由于切片是在元切片的基础上的抽象,因此新的切片和旧切片实际上指向的是同一个数组,故修改同一个索引的内容时会导致原切片的内容发生改变
1 | array := []int{1, 2, 3, 4, 5} |
三个索引的切片
创建切片时,第三个索引选项可以用来控制新切片的容量。⚠其目的并不是增加容量,而时限制容量。
1 | source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"} |
第三个选项也不可以超出索引范围!!!
按需增长可以说是切片的一个重要的特性。Go内置的append
函数会处理增长长度时所有的操作。
1 | slice := []int{1, 2, 3, 4, 5} |
因为newSlice
在底层数组里还有额外的容量可用,append会将可用的元素合并到切片的长度,并对其进行赋值。由于和原始的slice
共享同一个底层数组,slice中索引为3的元素的值也被改动了。
如果底层数组没有足够的可用容量,append会创建一个新的底层数组,将被引用的现有值复制到新数组里,再追加新的值。
append会智能地处理底层数组地容量增长,当切片容量小于1000个元素时总会成倍地增加容量,超过1000后容量的增长因子设为1.25(增长算法不恒定)
此外,通过...
操作符,把一个切片追加到另一个切片里。
1 | slice := []int{1, 2, 3, 4, 5} |
切片是一个集合,我们就可以迭代其中的元素。与python类似,Go有个特殊的关键字range
,它可以配合for
来迭代切片里的元素。
1 | slice := []int{1, 2, 3, 4, 5} |
代码中可以看到,迭代的时候会返回两个值: index
和value
,这里的value
是一个副本。
需要强调的是,range创建了每个元素的副本,而不是直接返回该元素的引用。
很多时候,我们使用迭代都不需要索引index
,此时可以使用占位符_
来忽略这个值:
1 | slice := []int{1, 2, 3, 4, 5} |
range
总是从头开始迭代。如果需要更多的控制,依旧可以使用传统的for循环:
1 | slice := []int{1, 2, 3, 4} |
有两个特殊的内置函数len
可以用于处理数组、切片和通道。对于切片来说,len
返回切片的长度,cap
返回切片的容量。
在函数间传递切片的时候,就是要以值的方式传递切片,因为切片的尺寸很小,在函数间复制和传递切片成本也很低。(因为切片的数据结构只是一个指向数组的指针、长度和容量,不是把整个数组复制)
1 | slice := make([]int, le6) |
正如标题所示,在《Go语言实战》中Map翻译成映射,相比于翻译相信Map更广为人知。
Map是一种数据结构(哈希表 or 散列表),用来存储一系列的键值对,如果你学习过别的语言相信看到这你就明白Map是什么了。在python
中这样的数据结构称为dict(字典)
、JavaScript
中称为json(JavaScript Object Notation)
Map是Go语言中哈希表的实现,因此我们每次迭代Map时打印的Key和Value时无序的,每次迭代都是不一样的。
Map的散列表中包含一组桶,在存储、删除或查找键值对的时候,所有操作都要线选择一个桶,如何选择桶?就是先把要查找的key
传给哈希函数,从而生成一个索引,进而找到对应的桶。
因此随着映射的增加,索引会分布的越来越均匀,因此访问键值对的速度就越快。(参考哈希表相关内容)由于本文主要是学习Go基础,因此不再继续深入,只要记住Map是无序的
Map的创建有如下几种方式:
make
函数声明1 | dict := make(map[string]int) |
map
字面量1 | // 不指定任何键值对->也就是一个空map |
Map的键可以是任何值,键的类型可以是内置的类型,也可以是结构类型,但是不管怎么样,这个键可以使用==
运算符进行比较,所以像切片、函数以及含有切片的结构类型就不能用于Map的键了,因为他们具有引用的语义,不可比较。
总结: 对于Map的值来说没有什么限制,但切片这种类型在键里不能用的,可以用在值里
Go语言的Map和别的语言都大同小异,使用非常简单和数组切片差不多
如果键张三存在,则对其值修改,如果不存在,则新增这个键值对
1 | dict := make(map[string]int) |
很多时候我们都要判断Map中是否存在某个键值对.在Go Map中,如果我们获取一个不存在的键的值,也是可以的,返回的是值类型的零值,这样就会导致我们不知道是真的存在一个为零值的键值对呢,还是说这个键值对就不存在。对此,Map为我们提供了检测一个键值对是否存在的方法。
1 | age, exist := dict["李四"] |
看这个例子,和获取键的值没有太大区别,只是多了一个返回值。第一个返回值是键的值;第二个返回值标记这个键是否存在,这是一个boolean类型的变量,我们判断它就知道该键是否存在了。这也是Go多值返回的好处。
如果我们想删除一个键值对,可以使用内置的delete
函数, delete
函数接受两个参数,第一个是要操作的Map,第二个是要删除的Map的键。
1 | delete(dict,"张三") |
delete函数删除不存在的键也是可以的,只是没有任何作用。
在Go中,我们可以使用range
迭代Map,这和遍历切片是一样的。
1 | dict := map[string]int{"张三": 43} |
rang
返回两个值,这和python是类似的,第一个是键,第二个是值。
函数间传递Map是不会制造副本的,也就是说如果一个Map传递给一个函数,该函数对这个Map做了修改,那么这个Map的所有引用都会被修改。
1 | func main() { |