程序員入門--兩年養成之路


標簽: 編程 工程 入門


備注:此文最早發表於公司內網,經脫敏處理后,部分內容可能讀起來邏輯不太順,或者殘缺部分內容

  
畢業進廠兩年了,在鵝廠兩年來的工作中,對編程和工程有了初步的理解和實踐,因此決定寫下來和大家一起交流,同時正值鵝廠的實習生和畢業生進廠搬磚之際,也希望能給新手們帶來一點收獲。

編程入門

  總結一下這兩年的編程經歷(拋開對業務的了解):通過編程和工程實踐,對計算機基礎的理解不斷加深,對程序的掌控力逐步增強。
  計算機的世界,是一個以bit(0,1)流為基礎,通過各種規則而構建起來的世界,而對於計算機基礎的學習,其實就是對規則的學習。編程入門第一關不單是學會一門語言的語法糖,而是意識到自己是一名程序開發者,條件反射一般以程序開發者的角度來重新審視計算機。舉一個不是很恰當的例子,我們肯定都接觸過word文檔,ppt,圖片,音視頻文件等,在普通人眼里這是各式各類的文件,而在程序開發者眼里這就是一串存放在磁盤上有着各自排列規則的bit(0,1)流,這些文件在被使用之后之所以有不同的表現形式,是因為各自的程序有着各自既定的解析規則。這些規則的制定者同樣是程序員,和你我一樣。
  1 程序結構的理解
  在開始在談程序結構之前,本來還應該提一下編譯,鏈接,程序裝載三塊(這三塊內容每一塊都能拿出來長篇大論一番,只講一些最基本的概念的話意義不大,因此此處不敢染指,推薦相關書籍《程序員的自我修養》),只有了解了自己的二進制文件如何生成,如何裝載進內存,其內存布局如何,心里才會更有底氣,在出現一些疑難雜症時,也能夠更准確快速的定位到根結,篇幅問題,這里不會談及,但我建議每一位后台開發人員都應該了解。在這里,先上一張老圖:
  c-cpp程序內存結構.png-13.9kB
  這是描述32位系統下程序大致內存結構的經典老圖(64位類似),具體就不贅述了。
  很多時候,對於初入編程領域的小白來說,對於知識的理解只是停留在"知道"這一層面(這是可以理解的,畢竟還未經歷實踐),一般來說也足夠應付一般的面試了。我們以一個簡單的運用為例:假如一個程序core了,該如何處理。當然,如果有core文件能夠追查到其堆棧是最好的,但如果沒有core文件呢?此時可以借助dmesg命令,輸入后會打印出程序發生錯誤時的內核錯誤碼和當前的ip寄存器的值,根據此值,我們可以追溯到core發生時的代碼。但是此方案存在一個缺陷,如果core是由圖中的.text段中的指令引起,那使用dmesg+ip寄存器值我們能直接定位core代碼,如果core是第三方的動態庫中的指令引起該怎么辦?其實也很簡單,只要找到該動態庫的裝載地址,再將ip值減去該地址即得到動態庫中的具體core指令地址,再通過objdump將其反匯編即可找到確定位置(編譯加了-g時,直接addr2line會更快速)。
  再看一張用於描述函數調用棧的經典老圖
  函數棧結構.png-9.8kB
  說明一下在x86_64架構下,當寄存器足夠存放參數時,是不會對參數進行壓棧的,因此圖中參數1到n(對應函數參數列表是從右到左)是可選的,當把上個棧幀的基址壓入棧中時,新的棧幀就開始了。
  相信開發同學們對於函數調用棧的結構早就清楚了,但是有沒有想過為什么c/cpp編寫的程序函數調用棧長這樣?其實沒有為什么,只是因為gcc編譯器是這么工作的,這是gcc為函數調用設計的規范(更合理的說法應該是編寫gcc的大佬們),不過其設計背后的原因其實也不難想到,1是因為各個函數的指令集在物理空間上是獨立的,自然需要處理指令的跳轉,2是需要解決輸入和輸出的傳遞,為什么輸入參數少的時候直接用寄存器呢?當然是因為CPU訪問寄存器更快,可惜寄存器個數有限,不然我們就不需要緩存和內存了(寄存器也是一片存儲空間,不同的寄存器名稱只是對不同的地址塊的引用而已)。
  也就是說gcc幫我們把c/cpp等高級語言編寫的代碼,按照規范轉化為了匯編指令,那如果我們直接用匯編語言編寫代碼,是不是就可以打破這一規范了?
  自然是可以的,下面我們結合協程一起理解。
 
  2 再談協程的實現
  這個話題其實已經被眾多同學分析過了,我們以libco為例,只看其核心的協程上下文切換的實現,其關鍵函數為coctx_swap,這里不過多的談及其實現過程(有興趣的同學可以閱讀libco協程原理簡要分析libco hook原理簡析),只看其開頭部分的幾條指令:

coctx_swap:
1   leaq 8(%rsp),%rax
2	leaq 112(%rdi),%rsp
3	pushq %rax
4	pushq %rbx
5	pushq %rcx
6	pushq %rdx
7	pushq -8(%rax) //ret func addr
	...

  為了方便描述,對每一行匯編代碼標注了行號,我們只需要關注第一行,第二行和第七行即可。
  首先可以確定的是cocox_swap函數肯定不是程序被執行的第一個函數(畢竟main函數也不是程序的入口函數),因此它必然是被某個函數調用而執行的,在它被調用之前,程序已經執行了這些操作(以下所有描述都建立在x86_64架構基礎之上):
  1 傳參,主要是傳遞給寄存器。當寄存器不夠用時,會叢右到左壓棧,然后再傳參給寄存器
  2 將返回地址壓棧,該地址一般指向上一函數中的下一條指令
  3 修改rip寄存器(指令寄存器)為調用函數的起始地址,新的函數開始了
  在談及第一,二,七行匯編代碼前,我們再來看經gcc編譯后的c/cpp函數,在函數開始時會做什么事情:
  1 將上個函數的棧幀基址(rbp寄存器用於存放棧幀基址)壓入棧中
  2 將rbp寄存器中的值修改為rsp寄存中的值,即開啟了新的棧幀
  現在可以看coctx_swap第一,二,七行到底在干了啥:
  1 將rsp寄存器中的值加上8得到的地址賦值給rax寄存器
  2 修改rsp寄存器中的值為rdi寄存器中的值加上112后的得到的地址,該地址正是其接下來要執行的協程的棧頂地址,此處極為關鍵,這相當於切換到另一片棧空間去了
  7 將rax寄存器中的值減去8得到的地址中的值壓如棧中,結合上面所提,rax寄存器減去8得到的地址所存儲的值,正是調用coctx_swap的父函數的下一條指令地址。
  總結一下,第二行實現了棧空間切換,第七行實現了返回地址的保存,(當然其它行也是在保存協程的上下文,在這里我們不關注),從而進程安心的切換到別的協程去了。
  從這里可以看到作者對於c/cpp程序結構的理解和運用。
  到現在為止,我們接觸到了進程,線程,協程等概念,它們各有差異隱約間又覺得具有共性,追根溯源,有沒有更基礎的概念來對它們進行統一的描述?有的,本質上它們都屬於執行體("執行體"是我自己順着思路說出的,不對其使用正確性和權威性負責),只不過是執行體的不同表現形式。那什么是執行體呢?
  簡而言之,執行體就是一段指令和一段私有的地址空間。當然,這段指令本身也將占用一段地址空間。
  由於代碼段是只讀(執行期間不可改變,這點是由操作系統保證)的,那么我們可以不嚴謹的這樣描述:給定下一條指令的地址和一段合法的地址空間,可以確定一個執行體。
  我們都知道,一個進程可以創建多個線程,多個線程共享該進程的地址空間,其原理就是這多個線程共享該進程的代碼段,以及在該進程的地址空間中專門分配了一段空間用於給該線程使用(當然線程還有一些其它私有變量,這里先忽略)。
  另外我們常說的"上下文",對應在執行體上是指什么呢?不嚴謹的說,對於一個執行體而言,其上下文就是它的地址空間(其執行期間的寄存器值也算入其地址空間中)和下一條指令的地址。
  
  3 軟硬結合
  首先想問一個問題:進程是操作系統里資源占用的最小單位,這句話有沒有毛病?在我還是編程小白時曾在網上看到過這句話,然后記了下來,碰巧還在面試過程中被問到過什么是進程,什么是線程,於是我直接背了出來"進程是操作系統里資源占用的最小單位,線程是CPU執行的最小單元..."。現在回過頭來看,其實這句話是有一點別扭,其核心點在於對"資源"一詞的理解,其遠不止獨立的地址空間這么簡單,計算機資源理應包括硬件資源。
  這一節主要想簡單介紹編碼過程中經常會遇到跟硬件相關的知識。這一節的內容可深可淺,我也只是掌握一些皮毛而已。另外此部分內容必須結合一些實例來理解。不過我們還是從經典老圖出發:
計算機存儲器層次.png-18.4kB
  金字塔從上到下,訪問速度下降,存儲空間更大同時造價更低(金字塔再往下走還有光盤,磁帶,由於工作中基本不會接觸到,此處就忽略了)。這基本是每個開發人員都知道的東西。一般而言,我們也只需關注磁盤這一塊,比如普通硬盤和固態硬盤的硬件結構和讀寫原理,兩者的順序讀寫和隨機讀寫的性能差異等,不過磁盤IO這一塊基本每一位開發也都十分熟絡了。這里就不多談。
  再往上走一層,就到了內存。我認為內存反而是對程序員比較透明的一層,因為操作系統幫我們管理好了,我們能介入的其實不多,大多數時候也就是在用戶態層面建立內存池,來進行復用,以及避免小內存的頻繁分配產生的系統調用。
  再往上走,就到了cache層和寄存器層了,我相信這兩塊相對於磁盤和內存就顯得陌生許多了。由於我們是使用c/cpp編寫程序,因此寄存器對我們來說也比較透明(由gcc編譯器幫我們進行管理及使用優化),唯一剩下的就是cache層了。
  盡管我們很少有機會從編碼層面去對cache層的使用做優化,但了解CPU cache結構以及CPU cache的MSEI協議還是很有必要的,對於CPU cache以及MSEI協議,網上已經有很多資料,這里不詳述,但還是簡要記錄一些關鍵點,以保證此文的完整性。
  
  cache基礎
  1 cache層次
  分為L1 cache,L2 cache,L3 cache,其中L1cache又分為L1 指令cache和L1 數據cache。
  在多核CPU中,每個核都有獨立的L1和L2cache,但是共享L3cache。
  2 cache line
  CPU cache是以cache line(緩存段)進行管理的,cache line的大小有32byte,64byte,128byte之分,因CPU差異而不同,現代的x86_64架構的CPU,cache line基本是64byte,由於cache line為CPU cache的最小管理單元,因此每次內存缺失都是以一個cache line的大小從內存中加載數據,以總線位寬64位計算,需要8次內存訪問
  3 cache 映射
  所謂的cache映射,是指主存的地址與cache的地址的映射規則,此處不詳述,可以有如下三種方式,全關聯映射,直接映射,多路分組映射,實際上現代CPU都是用的多路分組映射。
  
  MSEI協議
  上面已經說了,CPU的每個核都有自己的L1cache和L2cache。為什么不共用一套cache呢?因為這樣會導致每個指令周期只有一個CPU核能操作cache,其余CPU核必須等待才行(否則就全亂套了),從而使得整個系統都慢了下來。因此為了避免這種情況的發生,我們的每個CPU核都會有一套cache。但多套cache同樣帶來了一個問題,即如何保證數據的一致性。關於MSEI這里不再贅述,建議讀者讀完本文再繼續往下讀:緩存一致性(Cache Coherency)入門
  在了解了cache基礎和MSEI協議后,可以開始下面的部分了。
  雖說學以致用,但是我相信大部分人即使知道cpu cache的相關知識,卻依然不知道該在日常的編程之中能給我們帶來什么幫助,包括我自己也好不到哪去。下面談談目前為止我僅接觸到的一些實際案例(網上也有一些針對CPU cache的例子,不過大多不夠貼切現實,基本是對CPU cache的針對性測試)。
  1 重新認識死循環
  看如下代碼段:

int main(){
    int cnt = 0;
    while(1) cnt++;
    return 0;
}

  如果將其編譯鏈接生成可執行文件運行后,通過top命令,我們大概率會看到有一個CPU核的使用率是100%。這類死循環其實是對cpu和cpu cache的最高利用,我們基本上不可能寫出比它還高效的程序了,不過遺憾的是它不能幫我們做任何事情。但是為什么這幾行代碼能將CPU打滿?這說明CPU一直有活可干。這簡直是句廢話!再往深處想,CPU干活時需要什么基礎?自然是指令和數據都能即拿即用,也就是需要執行的指令位於L1 cache或寄存器,需要訪問的數據位於L1 cache或寄存器,由於在這個死循環中,指令只有短短幾行,數據只有一個int型變量,L1 cache的空間完全足夠緩存下它們(事實上編譯器很可能進行優化將cnt放到寄存器中),因此CPU才能不間斷的干活。在這里我們可以學習到,循環體內的指令和數據都盡量精簡,保證L1 cache能全部緩存到(否則會有淘汰,從而產生重復的cache line載入和換出)。不過這一點就跟死循環一樣雞肋,日常工作中,我們的代碼基本都是為了快速完成業務需求,多是考慮代碼可讀性,擴展性,根本不會管一段邏輯的指令/數據工作集有多大。

  2 內聯函數
  在我大學期間學習c++ primer時曾看到關於內聯函數的一段話,大意如下:使用內聯函數可優化程序性能,但是如果內聯函數太長,可能反而會降低程序性能。這句話當初雖然不明白,但也沒有深究,直到工作后才理解。
  我們都知道內聯函數在編譯時將會展開,即少了函數調用的那一系列操作(壓棧,跳轉,出棧,返回等),自然性能會有所提升,那為什么其太長時,可能會降低程序性能?
  在學習了CPU cache相關知識之后,其實就很好理解了,當內聯函數較長時,由於其將會進行展開,如果多次調用,將導致代碼段的膨脹,這有可能導致cache的利用率降低,可能的原因如下:
  1 該內聯函數的指令同時占用了多份cache空間(因為指令的地址不一樣,所以在CPU看來這就是不同的指令)
  2 程序整體的工作集變大了,這可能加劇指令cache的淘汰
  這一點對於日常工作其實幫助也不大,因為盡管我們可能不知道內聯函數為什么太長可能反而會降低程序性能,但實際上我們往往也是遵守這一約定的。
  
  3 緩存行運用
  上面有提到CPU cache的MSEI協議,我們知道Share狀態的緩存數據,是可能在多個cpu核的cache中存在的,一旦有CPU核需要修改時,需要將其變為Exclusive狀態,導致其它cpu核的cache都將失效。這對於我們編程有什么需要注意的地方嗎?

  當然是有的,比如對於如下一個結構

typedef struct{
    char always_read[32];
    char always_write[32];
}example;

  假設我們開辟了一片共享內存(注意,共享內存映射到進程空間的起始地址總是頁面大小的整數倍,反向可推出,被分配的example變量其起始地址為64的整數倍)用於存儲example結構,系統中有多個進程要對其頻繁進行訪問。系統的各個進程對其進行訪問時,對於always_read成員經常都是讀操作,偶爾才有寫操作,對於always_write成員總是讀寫操作。請問這樣設計有問題嗎?

  可以大膽的說,這樣當然沒有問題。但卻不是性能最優,回憶一下上面提到的知識點
  1 緩存段的大小一般是64字節(32和128的也存在,跟CPU架構有關,可通過cat /sys/devices/system/cpu/cpu1/cache/index0/coherency_line_size 命令進行查看不同級別cache的緩存段大小)
  2 每個cpu核都有一組cache
  3 如果某個cpu核對某個cache進行修改后,其余cpu核的對於該cache的緩存段都失效了

  對於上面的設計,example結構中的always_read和always_write恰好落在一個緩存段中,即每次有cpu核對always_write改寫時,連帶着其余cpu核中的always_read也一同失效了,當其余cpu核要對其進行讀操作時,必須重新從內存中加載該數據,這會造成很多無意義的緩存缺失情況。

  如何解決這種尷尬的情況呢?其實也很簡單,我們直接看代碼

typedef struct{
    char always_read[32];
    char padding[32];
    char always_write[32];
}example;

  如上,我們只需要將always_read和always_write划分到不同的緩存段中即可。
這一點對於平常的工作其實也沒什么幫助,但是在設計一些基礎組件時(如頻率控制)也許能用上。除此之外,由於CPU cache每次載入內存都以cache line為單位的特性,我們可以知道,不合理的條件分支代碼也可能降低程序性能,相應的,合理的使用likely和unlikely關鍵字則可以提升程序性能。其中likely和unlikely的作用便是告訴gcc把相關分支里的指令提前或者移后,以保證cache的命中率。
  
  從編碼的角度來看,我暫時接觸到的也只有這么一些。在工程入門部分還將會有對CPU cache的應用。
  
  5 原子操作
  軟件實現部分不提,這里只提硬件支持的原子操作,我們先看一下intel的cpu支持的原子操作:
  說明:本段內容截選自[原]原子操作及對C++編程的意義,網上有的資料,這里就不再翻譯一遍了:

《Intel 64 and IA-32 Architectures Software Developer`s Manual》Volume3 System Programming Guide,8.1.1 Guaranteed Atomic Operations中講解的原子操作如下:
  Intel486處理器(以及以后生產的處理器)確保以下對基本存儲器的操作行為為原子操作:
  
  Reading or writing a byte
  讀寫一個byte
  
  Reading or writing a word aligned on a 16-bit boundary
  讀寫16bit(2byte)內存對齊的字(word)
  
  Reading or writing a doubleword aligned on a 32-bit boundary
  讀寫32bit(4byte)內存對齊的雙字(dword)
  
  The Pentium processor (and newer processors since) guarantees that the following additional memory operationswill always be carried out atomically:
  
  Pentium系列處理器(以及以后生產的處理器)確保以下對基本存儲器的操作行為為原子操作:
  
  Reading or writing a quadword aligned on a 64-bit boundary
  讀寫64bit(8byte)內存對齊的四字(quadword)
  
  16-bit accesses to uncached memory locations that fit within a 32-bit data bus
  使用16bit訪問的未緩存的內存,並且這些內存適應32位數據總線(翻譯不好)
  
  The P6 family processors (and newer processors since) guarantee that the following additional memory
  operationwill always be carried out atomically:
  P6系列處理器(以及以后生產的處理器)確保以下對基本存儲器的操作行為為原子操作:
  
  Unaligned 16-, 32-, and 64-bit accesses to cached memory that fit within a cache line
  對單個cache line中緩存地址的未對齊的16/32/64位訪問(非對齊的數據訪問非常影響性能)
  
  Accesses to cacheable memory that are split across cache lines and page boundaries are not guaranteed to 
  beatomic by the Intel Core 2 Duo, Intel®Atom™,Intel Core Duo, Pentium M, Pentium 4, Intel Xeon, P6 
  family, Pentium, and Intel486 processors. The Intel Core 2 Duo, Intel Atom, Intel Core Duo, Pentium 
  M,Pentium 4, IntelXeon, and P6 family processors provide bus control signals that permit external memory
  subsystems to make splitaccesses atomic; however,nonaligned data accesses will seriously impact the 
  performance of the processor andshould be avoided.
  那些被總線帶寬、cache line以及page大小給分隔開了的內存地址的訪問不是原子的,你如果想保證這些操作是原子的,你就得求助於機制Bus Lock,對總線發出相應的控制信號才行。
  
  An x87 instruction or an SSE instructions that accesses data larger than a quadword may be implemented 
  usingmultiple memory accesses. If such an instruction stores to memory, some of the accesses may 
  complete (writingto memory) while another causes the operation to fault for architectural reasons (e.g. 
  due an page-table entry thatis marked “not present”). In this case, the effects of the completed 
  accesses may be visible to software eventhough the overall instruction caused a fault. If TLB 
  invalidation has been delayed (see Section 4.10.4.4), suchpage faults may occur even if all accesses are
  to the same page.

  為什么要在編程入門中提原子操作呢?這有助於加深我們對於CPU訪問數據的理解,提煉一下關鍵點:CPU的地址總線寬度,數據總線的寬度,數據總線訪問內存時要求的地址對齊(為了降低硬件設計復雜度)。另外其實原子操作在並發編程里實現無鎖操作的過程中至關重要,本人之前寫過一篇文章:基於nginx的頻率控制方案思考和實踐,在共享內存的設計過程中,有很多的結構/變量都是保證其內存對齊的,一部分原因自然是因為保證一次內存訪問以提升程序性能,更重要的是為了保證其讀/寫的原子性。
  
  5 再談TCP/IP
  關於TCP/IP的文章實在是太多了,但是同樣存在一個問題,大多數的文章都只是在重復的闡述TCP的一些協議內容,缺少一些運用和見解,順便在這里吐槽一下,網上的資料雖然多,但是存在大量重復無營養的內容,經常導致我們檢索一些問題時帶來很多無用的點擊。正如文章開頭所述,計算機基礎的學習是對規則的學習,對於規則不能只會背誦,只有理解其設計背后的考量才能靈活運用,本人之前也寫過一篇關於TCP的文章:TCP隨筆,此文的后半段融入了一些我對TCP部分設計的一些思考和理解。有興趣的同學可以點擊閱讀一下,這里就不再贅述了。
  
  6 百練成鋼
  百練其實是指刷題。刷題是提升/保持編碼能力,錘煉邏輯思維最直接有效的手段,而且基本面試的時候都會被問算法題,有了日常積累那就無須再額外花時間去專項練習,一舉雙得。刷題有兩點好處,1是工作中大多數的時候都是在重復的針對某類問題進行編碼,如果沒有碰上新問題的話,思維很難變得更進一步,而刷題過程中往往能碰到各式各類的題目,百煉才能成鋼。2是刷題會讓你對輸入和輸出變得更加敏感,能提升我們(在日常的工作中)對於異常輸入的風險意識,進一步降低bug率。因此如果時間充足的話,我認為每天都應該刷刷題(水題即可),時間比較緊張的話,可以一周刷個3,4道。長此以往,編碼能力一定能到達/維持在一個較高的水平。
  

工程實踐

  在我工作初期,編碼時考慮的只是把這個問題/需求處理掉,這導致我后來負責的不少模塊越來越不可維護... 不過隨着大量編碼和一些系統設計經歷,以及在這過程中遇見的一些問題,也讓我逐漸開始對工程實踐產生思考,當然工程實踐是一個很大的話題,以我的水平難以講出干貨。
  1 規范
  把規范放在第一點,是因為它很重要,但是又很容易被團隊輕視的一個點。我認為團隊應該形成共同的規范,並整理出文檔,以便在我們在編碼和項目管理過程中能時時參考,嚴格遵守,同時新人入伙時,也應該先學習團隊的規范,以確保能寫出符合團隊風格的代碼來。不過非常遺憾的是在我入職初期時沒有被教育過,自己的這種想法在當時也還不強烈,因此寫出的代碼非常臘雞,給團隊留下了毒瘤,直到后面我自己也看不下去了。
  1.1 項目管理規范
  簡單來說,這里其實想談及的是關於目錄的划分和代碼的存放位置。一個很常見的點是,很多人都喜歡搭"小金庫",其實搭小金庫也並沒有什么,關鍵是這樣很容易導致同一份代碼被拷貝至多處,一是這樣會導致代碼難以維護,比如需要修復某個bug時,很容易就漏掉了別的"備份",二是冗余的代碼文件是對整個項目的一種污染。對於具備通用性的代碼庫,應該盡可能的存放在公共庫路徑下,而不是搭建小金庫,另一點是存放在公共庫路徑中的代碼應盡可能的具備擴展性,以滿足不同的需求,這需要比較深厚的編程功底。
  其次是項目目錄及其代碼文件/庫的規范管理,分公共庫和其它兩點來講。其它包括一些基礎項目(如日志系統,配置系統),cgi,logic服務等,對於其它,我們主要關注的是項目的根目錄在整個代碼庫中的路徑,及其子目錄划分和對應的代碼文件存放。
  對於公共庫的目錄管理及代碼文件/庫的規范管理,在根據代碼的功能將其存放在恰當的路徑的前提之下,我一直都有一個想法是這樣的:是否可以如同godoc一樣,為我們的c++公共庫自動創建一份文檔。有了這樣一份文檔將為我們的日常開發提供很大的助力。有這種想法的原因是很早之前使用過一段時間golang,發現利用godoc生成的頁面來查詢包功能,函數功能非常方便。這里只是提出這種想法,真正要實施起來可能有不少東西都可以參考golang的設計(如利用函數首字母大寫表示該函數是給調用者使用的從而導出至生成的頁面中,比如注釋等)。

  1.2 命名風格規范
  主要是兩點,一是目錄的命名規范,二是代碼的命名規范,命名風格其實是要跟項目管理規范結合在一起來實施的,這里細的部分就不多談了,命名規范一方面是為了保證代碼風格統一,可讀性更佳,其實另一方面也可以減少我們的代碼出錯率,例如在多層循環中使用i,j,k作為下標時,其實很容易用錯下標。當我們對一個變量或者文件進行命名時,需要停頓並思考幾秒時,說明已經有效果了。
  
  2 系統設計考量
  這里不會涉及到諸如可用性,擴展性,靈活性,可維護性,健壯,可靠等概念,正如文章開頭所述,屬於入門系列,在這里我將描述一下系統設計時的常規流程(當然這只是個人的學習體會和經驗之談),遵照流程進行設計也許不能幫助你設計出牛逼的系統,但是一定能幫你避免不少坑。
  首先要確立一個中心思想,借用之前領導常說的兩句話:復雜問題簡單化,大系統小做。正如沒有最好的架構,只有最合適(匹配業務)的架構,我對這兩句話的理解是:通過對業務特性/需求背景的深度理解和把控將復雜問題理順后進行抽象得到簡單,輔以層次->模塊->功能的合理划分來設計我們的系統。
  
  在系統設計之初,我們首先需要搞清楚的是需求背景和明細。一方面自然是因為它們本身就是系統的功能點,另一方面我們需要盡可能的提前把握好系統未來要擴展的方向,從而在系統設計中考慮進去。
  
  在清楚了需求背景和明細之后,我們需要做的是了解系統將面臨的請求壓力。第一步自然是預估其qps數,這里雖然是預估,但也必須有可靠的數據支撐,視情況而定,一般系統設計會以當前容量的2~N倍來設計,主要考量點在於業務的增長速度。接下來需要明確的是,系統是屬於cpu型還是io型的服務,內存需求如何,硬盤需求如何等等,網卡流量要求等等,在把系統將面臨的壓力確認之后便可以開始進行初步設計。
  
  在初步設計階段,我們需要進行技術選型以及設計系統的整體架構。對於技術選型,我們以數據存儲為例,是采用文件存儲,還是采用redis,還是mysql,還是mongodb等,各項存儲技術對於我們的存儲需求的讀寫性能指標是多少,后續如何擴容,以及對不同技術方案的開發成本/機器成本進行預估,進行綜合之后得到我們的存儲方案,由於技術選型與系統的整體架構是聯系在一起的,基本上有了合適的技術方案時,也就有了對應的系統架構。接下來便進入到詳細設計階段。
  
  在詳細設計階段,我們需要對系統的各個角色的關鍵部分進行詳細的設計,將涉及到的問題和難點一一給出詳細的解決方案。比如對於有文件存儲需求或是(共享)內存需求的服務,我們需要給出詳細的數據存儲格式,數據更新寫入讀取的方式等,以保證該方案的可行性。
  
  在詳細設計完畢之后,我們需要計算在設計容量和詳細設計方案之下,該系統所需要的硬件資源是多少,並根據硬件資源的總/單機需求來選取合適的機型,最后得到系統所需要的機器數量及型號。
  到此一個常規的系統設計流程就結束了。
  
  3 開發->運維
  在工作職責上,開發和運維的工作內容有着明顯的區分度,但是從工程的角度,開發和運維是緊密聯系的,它們對我們的系統服務都是至關重要的。此處我以部署優化為例進行講解(另一塊是系統的運維成本,這在系統設計過程中也需要考慮進去)。
  常規的部署優化,如避免單點故障,想必大家都很清楚且容易進行運用,除此之外還有一個非常關鍵但容易被開發同學忽視的部署問題,跨機架/機房/地域下的機器之間的網絡延時。公司的機房之間的通信走的是公司自己搭建的骨干網,即便這樣,天津與深圳機房的RTT還是高達幾十ms,要是走運營商網絡耗時必然倍增。從這組數據,可以認識到以下幾點
  1 機器不同部署情況下的網絡延時甚至能對我們的系統設計方案產生影響,跨地域的機房RTT可能比我們的服務一次請求處理的耗時都要高
  2 機器部署優化,可以有效降低系統的整體耗時(進而可能降低失敗率),提升服務整體質量
  3 用戶的就近接入的必要性
  4 靜態資源上cdn(內容分發網,簡單來說就是一群靠近用戶網絡節點的網絡節點)的必要性
  
  3 對於業務系統的整體認識
  此節較簡單,意在每位開發應該對整個業務系統有較清晰的認識,如從用戶發出請求到得到返回需要經過的完整鏈路,整個業務系統中有哪些角色及各自的功能,這能夠讓我們更清楚自己設計的系統在整體中的定位,從而考慮更加完備。某種程度上來說,這其實也是要求開發需要對整個產品業務有着較清晰的認識。
  
  4 關於后台框架的實現
  后台框架是我們實現網絡服務的基石,對於后台框架的理解能夠加深我們對於網絡服務的掌控力,下面我們從零開始,看看后台框架的實現方案。
  依然還是從經典老圖出發,讓我們回顧一下馮·諾伊曼模型
  image_1cj52jpg3btapgo1gerihgdfl2g.png-13.8kB
  
  可能讀者會疑惑,馮·諾伊曼模型不是計算機的設計理念嗎,為什么要放到這里來?我們看下圖:
  image_1cj51k3sb1stf1vc11f76l4itlk9.png-12.8kB
  我們的網絡服務程序是通過對計算機硬件資源的利用來對數據做加工處理。其核心驅動是什么?是數據。現在我們把硬件元素去掉,看一看常規的網絡服務程序的實現模型:
  image_1cj51rh731fnnnj1qnj1bd6123523.png-18.5kB
  
  從圖中可以認識到:通過IO復用模型,以數據為驅動,執行相關的邏輯。其實這也就是我們后台框架的基礎模型了,只不過后台框架是集成了一系列功能模塊的程序,如路由模塊,日志模塊,監控模塊等,最后提供人性化的業務邏輯處理接口供開發者使用。
  上面的兩張圖都沒有體現進程/線程的概念,不同的后台框架可能會有不同的進程/線程模型,而不同的實現模型自然也會有不同的性能,而評估性能最直截了當的指標就是其對硬件資源的有效利用率,何謂有效利用率?以最簡單的Accept-Fork-Exec模型為例,其對硬件資源的有效利用率就非常低,大量的資源浪費在了創建進程/線程和前后的准備工作上。下面我們以使用了IO復用模型,進程/線程均為常駐類型的為例進行講解,另外,后台框架中都有類似於管理員(Master)的角色(進程/線程),用於對整體后台框架進行監控管理,在這里我們不將其納入討論范圍。
  本質上,我認為后台框架可以分為兩類,一類是Proxy And Worker,一類是ProxyWorker。兩者的大致結構可以見下圖
  image_1cj19tgbb18dn17hriun12vabr99.png-40.3kB
  Proxy And Worker類的,其工作模式為有專門的Proxy負責接收請求,待將請求接收完整之后,將請求內容寫入請求隊列並通知Worker,之后由Worker從隊列中取出處理完成后寫入回包隊列,並通知Proxy,Proxy從回包隊列中取出進行回包。
  
  ProxyWorker類的,是將Proxy和Worker的功能集中在了一起,即只有Worker進程,每一個Worker進程既需要監聽端口接收請求和回包,也需要處理請求內容。這類后台框架中,典型的就是nginx。
  
  簡單分析以下兩類框架各自的優劣,對於Proxy And Worker類的,由於Proxy,Worker屬於不同的進程,地址空間隔離,因此需要IPC來進行通信,事實上對於請求/回包內容等大數據量的通信,往往都是用的共享內存,因此其至少都會多兩次數據拷貝,分別是Proxy到共享內存,再從共享內存到Worker,回包內容則是從worker到共享內存再到proxy。 其次如果Proxy是單進程的,Proxy容易成為程序的性能瓶頸,改良方法自然就是多Proxy。相對的,這種模式下存在以下兩種優點,一是由於Proxy負責請求的分發,該模式下對於請求的路由更為可控,可將不同的請求分發給不同類別的Worker(當某類Worker只有一個進程時,則可直接定位到進程,這對提供緩存的服務很有幫助)。二是由於Proxy與Worker分離,整體框架的穩定性較高,即在業務邏輯處理的過程中,部分Worker掛掉了,Proxy不受影響,而Master也可以即時監控到Worker進程異常退出,從而再次拉起Worker進程,在這個過程中,其余Worker沒有受到影響。
  
  對於ProxyWorker類的,由於請求監聽接收回包與業務邏輯處理同屬於一個進程,用的同一片地址空間,因此可以避免掉請求/回包數據的多次拷貝,同時也避免掉了單Proxy的性能瓶頸。從性能上來說,這類框架的性能更加強勁,特別是當運用上協程時,我們可以方便的寫出無阻塞操作的代碼,此時將每個ProxyWoker進程各自綁定一個cpu核(提升cache命中率),同時提高它們的優先級(進一步將硬件資源有效利用率提升),可以得到最佳的性能,但是這類框架對於請求的路由不可控(因為沒有Proxy來分發),無法精確定位到進程,這對緩存類服務的實現帶來一定挑戰,同時也容易導致請求分配不均勻。在穩定性方面,由於是多進程模型,部分Worker掛掉,依然能提供服務,但是其它Worker進程的負擔將會變大,另外正如上面所說請求分配難以均勻,因此穩定性方面稍差。最后是驚群問題,雖然accept的驚群問題已被解決,但是使用IO復用模型后,依然會有驚群問題產生。
  
  由於個人喜好的原因,上面一直沒有提到線程,主要是因為我認為對於c
pp類的后台服務而言,多進程+協程是最好的選擇,其穩定性/性能都是最佳。(多線程程序的編碼難度較大,且由於地址空間共享的原因更容易出錯,另外某個線程掛了,整體就掛了)
  
  5 安全
  雖然我不是從事安全方面工作的開發,但是我對安全還是持有一定的興趣。對於安全,我們首先要在腦海里樹立一種認知:我們的輸入是不可信的。當我們有了這種認知后,我們自然就會在系統設計中將安全考慮進去。
  當然畢竟我不是職業做安全的同學,對安全的見解還是比較淺薄的,不過我認為對於開發同學而言,了解了以下兩點基本也足夠日常應用了。
  1 數據安全
  數據安全主要是指我們的通信過程是可能被監聽的,因此對於敏感的場景,我們需要對通信過程進行加密。在這里,我們需要了解常規加密算法的實現原理,對稱加密和非對稱加密的區別和優劣,再輔以學習https的通信過程及其設計背后的考量等,這里就不再詳述了,網上信息很多。
  2 協議安全
  協議安全是指在我們的協議設計過程中,需要將安全性考慮進去,設計出安全可靠的協議。以一個簡單的例子來說明,比如需要設計一個修改用戶昵稱的接口,其請求協議如下:

Req {
    int64 uid;
    string nickname;
}

  uid即需要被修改的用戶的id,nickname為新的昵稱。很明顯,這個協議是不安全的,用戶可以通過這個協議修改任意uid的昵稱。如何避免呢?這個場景中的關鍵點在於我們需要明確操作者是uid本人,因此我們可以這樣做:在協議中加上一個access_token字段,該access_token由用戶登錄后生成(具備時間有效期屬性),我們可以根據該access_token換取回uid,再對比請求中的uid是否一致即可確認操作者是否是操作者本人。這里為什么有了access_token后協議里面還需要uid字段呢?從功能角度來看,確實已經沒有必要性了,但是我們可以以此監控uid與access_token不一致的請求次數,有必要的話還可以對惡意請求的請求信息進行上報統計打擊。
  

結尾

  以上是對我在鵝廠兩年的編碼生涯的一個總結,由於個人水平和認知有限,文中內容也許有不少錯誤,歡迎指出。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM