27 tháng 7, 2019

Go Scheduler

Go Scheduler

Trong bài viết này, chúng ta sẽ cùng tìm hiểu xem Go Scheduler hoạt động như thế nào, thông qua đó ta có thể đưa ra các quyết định về mặt kỹ thuật tốt hơn khi lập trình bằng ngôn ngữ Go.

Khi một chương trình phần mềm được viết bằng ngôn ngữ Go khởi động, nó được cung cấp một Bộ vi xử lý ảo - Logical Processor (P) cho mỗi virtual core trên máy host. Nếu bạn có một bộ vi xử lý với nhiều hardware thread trên mỗi physical core (Hyper-Threading) thì chương trình của bạn sẽ coi mỗi hardware thread như một virtual core.

Ví dụ, giả sử chúng ta có thông số kĩ thuật sau của một máy tính Macbook Pro:

Hình 1:

Hình 1

Như bạn có thể thấy, chúng ta có một bộ vi xử lý đơn với 4 physical core. Theo như những thông số trên thì chúng ta không thấy thông số nào nói đến số lượng hardware thread trên mỗi physical core. Tuy nhiên, bộ vi xử lý Intel Core I7 có tính năng Hyper-Threading, điều này có nghĩa là sẽ có 2 hardware thread cho mỗi physical core. Vậy nên chương trình của bạn sẽ có thể hiểu là có 8 virtual core sẵn sàng để thực thi song song các tác vụ trên các OS Thread.

package main

import (
	"fmt"
	"runtime"
)

func main() {

    // NumCPU returns the number of logical
    // CPUs usable by the current process.
    fmt.Println(runtime.NumCPU())
}

Khi chúng ta thực hiện đoạn code trên với những thông số đã xem qua của máy Macbook Pro thì kết quả NumCPU() sẽ là 8. Mọi chương trình khi chạy sẽ đều được cung cấp 8 P. Mỗi P sẽ được gán cho một OS Thread (M) (M là viết tắt của từ machine). Các Thread này sẽ được quản lý bởi OS và OS vẫn chịu trách nhiệm cho việc đặt Thread lên Core trong quá trình thực thi. Điều này có nghĩa là mỗi khi chúng ta khởi chạy chương trình, chúng ta sẽ có 8 thread sẵn sàng để thực thi công việc, mỗi thread được đính kèm vào một P.

Mọi chương trình Go cũng được cung cấp một Goroutine ban đầu (G), Goroutine giống như OS Thread nhưng mà là ở tầng ứng dụng. Cũng giống như các OS Thread được chuyển đổi ngữ cảnh (context-switch) trên một core, các Goroutine cũng được chuyển đổi ngữ cảnh trên M.

Có 2 run-queue khác nhau trong Go scheduler: Global Run Queue (GRQ)Local Run Queue (LRQ). Mỗi P được cung cấp một LRQ quản lý các Goroutine đã được chỉ định để thực thi bên trong context của P. Những Goroutine này sẽ lần lượt được switch-context on hoặc off trên M mà đã được gán cho P. GRQ dành cho các Goroutine chưa được gán cho một P nào. Ta sẽ thảo luận về tiến trình dịch chuyển các Goroutine từ GRQ sang LRQ trong phần sau.

Hình 2:

Hình 2

Cooperating Scheduler

Như chúng ta biết thì OS scheduler là một Preemptive scheduler. Về cơ bản thì điều này nghĩa là chúng ta sẽ không dự đoán được scheduler sẽ làm gì và vào bất kì lúc nào. Kernel sẽ chịu trách nhiệm đưa ra quyết định và mọi thứ sẽ không thể xác định rõ ràng. Các ứng dụng chạy trên OS không có quyền kiểm soát những gì đang xảy ra bên trong Kernel bằng scheduler trừ khi chúng tận dụng atomic hoặc mutex.

Go scheduler là một phần của Go runtime, và Go runtime được tích hợp vào trong ứng dụng của bạn. Nghĩa là, Go scheduler sẽ chạy trong User space, phía trên Kernel. Hiện tại, cách triển khai Go scheduler không phải là Preemptive scheduler mà là một Cooperating scheduler. Với việc là một Cooperating scheduler nghĩa là scheduler này cần các sự kiện User space được xác định rõ ràng để có thể đưa ra quyết định schedule.

Goroutine States

Cũng giống như Thread, Goroutine cũng có ba trạng thái high-level, chúng chỉ ra vai trò Go scheduler đối với một Goroutine bất kì. Một Goroutine có thể ở một trong ba trạng thái: Waiting, Runable hoặc Executing.

Waiting: Nghĩa là Goroutine bị dừng và đang đợi điều gì đó để tiếp tục. Điều này có thể là vì chờ hệ điều hành phản hồi (system call) hoặc lời gọi đồng bộ (atomic hoặc mutex). Đây là những nguyên nhân gốc rễ dẫn đến hiệu năng kém của một chương trình.

Runnable: Nghĩa là Goroutine đã sẵn sàng để thực thi các chỉ dẫn đã được chỉ định trên M. Nếu chúng ta có nhiều Goroutine cùng trong trạng thái này thì cũng có nghĩa là các Goroutine cũng sẽ phải đợi lâu hơn đến đến lượt thực thi.

Executing: Nghĩa là các Goroutine đã được đặt trên một M và đang thực thi các chỉ dẫn của nó. Công việc liên quan đến ứng dụng đang được thực hiện.

Context Switching

Go Scheduler yêu cầu các sự kiện User-space được xác định rõ xảy ra tại các safe-point trong code để thực hiện context-switch. Các sự kiện và safe-point này được thể hiện bên trong các lời gọi hàm. Các lời gọi hàm rất quan trọng đối với trạng thái của Go Scheduler. Từ Go version 1.11 trở xuống, nếu bạn chạy một vòng lặp đơn giản bất kì mà không thực hiện các lời gọi hàm thì có thể sẽ gây ra độ trễ bên trong Scheduler và Garbage Collection. Các lời gọi hàm phải xảy ra bên trong một khung thời gian hợp lí là cực kì quan trọng.

Có bốn loại sự kiện xảy ra trong các chương trình Go cho phép Scheduler đưa ra các quyết định schedule. Điều này không có nghĩa là nó sẽ luôn luôn xảy ra ở một trong các loại sự kiện này, mà chỉ là Scheduler sẽ có cơ hội để đưa ra quyết định schedule.

Bốn loại sự kiện được nhắc tới là:

  • Sử dụng từ khóa go

  • Garbage collection

  • System call

  • Synchronization và Orchestration

Sử dụng từ khóa go

Từ khóa go giúp bạn tạo mới các Goroutine, khi một Goroutine được tạo mới thì nó sẽ chờ được schedule bởi Go Scheduler.

Garbage collection

GC chạy bằng cách sử dụng chính các Goroutine của riêng nó nên các Goroutine này cần thời gian để thực thi ở trên M. Điều này dẫn đến việc tạo ra nhiều sự hỗn loạn khi schedule. Tuy nhiên, Go Scheduler đủ "thông minh" để đưa ra các quyết định hợp lí. Một trong các quyết định thông minh là thực hiện context-switch cho một Goroutine muốn sử dụng đến Heap trong quá trình GC. Khi đang trong quá trình GC, rất nhiều quyết định schedule sẽ được đưa ra.

System call

Nếu một Goroutine thực hiện một lời gọi system call sẽ khiến Goroutine này bị block trên M, đôi khi scheduler sẽ có thể thực hiện context-switch off Goroutine này trên M và thực hiện context-switch một Goroutine mới ngay trên M này. Tuy nhiên, thỉnh thoảng một M mới sẽ được tạo để tiếp tục thực thi các Goroutine đang trong hàng đợi của P. Chúng ta sẽ tìm hiểu chi tiết về điều này trong phần sau.

Synchronization và Orchestration

Nếu một atomic, mutex hoặc channel được gọi sẽ dẫn đến Goroutine bị block, Go Scheduler có thể thực hiện context-switch một Goroutine mới để tiếp tục thực thi công việc. Một khi Goroutine bị block có thể tiếp tục thực thi tiếp thì nó có thể được cho trở lại vào hàng đợi và đợi đến lượt được context-switch.

Các System-call bất đồng bộ

Khi hệ điều hành của bạn có khả năng xử lý bất đồng bộ một system-call thì network-poller có thể được sử dụng để xử lý system-call này hiệu quả hơn. Điều này có thể thực hiện được là nhờ vào kqueue trên MacOS, epoll trên Linux hoặc iocp trên Windows.

Ngày nay, các system-call dựa trên network có thể được xử lý bất đồng bộ nhờ vào hệ điều hành của bạn. Bằng cách sử dụng network-poller, Go Scheduler có thể ngăn chặn được việc các Goroutine bị block trên M khi các system-call kiểu này được tạo ra. Điều này sẽ giúp cho M tiếp tục được sử dụng để thực thi các Goroutine khác trong LRQ mà không phải tạo ra thêm các M. Nó sẽ giúp giảm thiểu thời gian tải của quá trình schedule trên hệ điều hành.

Hình 3:

Hình 3

Nhìn vào hình 3 chúng ta có thể thấy Goroutine-1 đang thực thi công việc trên M và có 3 Goroutine khác đang đợi đến lượt trong LRQ. Network-poller thì đang nhàn rỗi.

Hình 4:

Hình 4

Trong hình 4, Goroutine-1 muốn thực hiện một network system call, vì vậy Goroutine-1 được chuyển đến cho network-pollernetwork system call sẽ được xử lý bất đồng bộ. Một khi Goroutine-1 được chuyển đến network-poller, thì bây giờ M sẽ thực thi công việc của một Goroutine khác trong LRQ. Như hình 4 thì Goroutine sẽ được context-switch trên M.

Hình 5:

Hình 5

Trong hình 5, network-poller hoàn thành xử lý bất đồng bộ network system call và Goroutine-1 được chuyển lại cho LRQ. Khi Goroutine-1 đến lượt context-switch trở lại, có đoạn code mà nó chịu trách nhiệm xử lý có thể được thực thi lại. Điểm lợi lớn nhất ở đây đó là khi ta thực thi các network system call thì không cần phải tạo thêm M. Network-poller có một OS Thread và nó xử lý event-loop rất hiệu quả.

Synchronous System Call

Vậy điều gì sẽ xảy ra khi một Goroutine muốn thực hiện một system-call không thể được hoàn thành bằng xử lý bất đồng bộ? Trong trường hợp này, network-poller không thể được sử dụng và Goroutine tạo ra system-call sẽ bị block trên M. Chúng ta sẽ không có cách nào để ngăn điều này xảy ra. System-call khi xử lý file là một ví dụ điển hình của một system-call không thể xử lý bất đồng bộ.

Hình 6:

Hình 6

Trong hình 6, Goroutine-1 tạo một synchronous system call dẫn đến nó sẽ bị block trên M1

Hình 7:

Hình 7

Trong hình 7, Go Scheduler có thể xác định được Goroutine-1 gây ra block trên M. Lúc này, Go Scheduler sẽ tách M1 khỏi P kèm theo Goroutine-1 đi với nó. Sau đó, Go Scheduler tạo ra M2 để phục vụ cho P. Lúc này, Goroutine-2 có thể sẽ được lựa chọn từ LRQ và được context-switch trên M2. Nếu một M đã tồn tại từ trước, quá trình chuyển đổi này sẽ diễn ra nhanh hơn so với việc phải tạo mới M2

Hình 8:

Hình 8

Trong hình 8, blocking system call được tạo bởi Goroutine-1 kết thúc. Lúc này, Goroutine-1 được chuyển lại cho LRQ và được xử lý lại bởi P và kịch bản xử lý system call có thể lại tái diễn một lần nữa.

view raw Go Scheduler.md hosted with ❤ by GitHub

25 tháng 6, 2019

Kiến trúc Kubernetes: Nodes

Kiến trúc Kubernetes: Nodes

Một node là một worker machine bên trong Kubernetes, trước đó còn được biết như là một minion. Một node có thể là một VM hoặc một physical machine, điều này phụ thuộc vào cluster. Mỗi node sẽ chứa các service cần thiết để chạy các pod và được quản lý bởi các master component. Các service trên một node bao gồm container runtime, kubeletkube-proxy.

Node Status

Status của một node bao gồm các thông tin sau:

  • Addresses
  • Conditions
  • Capacity và Allocatable
  • Info

Node status và các thông tin chi tiết khác về một node có thể được hiển thị bằng cách sử dụng lệnh sau:

kubectl describe node <insert-node-name-here>

Addresses

Cách sử dụng của các trường này sẽ khác nhau tùy thuộc vào cloud provider của bạn hoặc cấu hình vật lý:

  • HostName: hostname được ghi lại bởi kernel của node. Có thể được ghi đè qua tham số kubelet --hostname-override

  • ExternalIP: địa chỉ IP của node có thể được định tuyến ở bên ngoài cluster

  • InternalIP: địa chỉ IP của node chỉ có thể được định tuyến bên trong cluster

Conditions

Trường conditions mô tả trạng thái của tất cả các node Running. Ví dụ, conditions sẽ bao gồm:

Node Condition Mô tả
OutOfDisk True nếu node không có đủ bộ nhớ để tạo thêm các pod mới, ngược lại là False
Ready True nếu node tình trạng tốt và sẵn sàng chấp nhận thêm pod, False nếu node không tình trạng tốt và không chấp nhận thêm pod, Unknown nếu node controller không nghe thấy được tín hiệu từ node-monitor-grace-period cuối cùng (mặc định là 40 giây)
MemoryPressure True nếu bộ nhớ của node ở mức thấp, ngược lại là False
PIDPressure True nếu có quá nhiều process chạy trong node, ngược lại là False
DiskPressure True nếu dung lượng đĩa cứng thấp, ngược lại là False
NetworkUnavailable True nếu network của node không được cấu hình đúng, ngược lại là False

Node condition được khai báo như một đối tượng JSON. Ví dụ, response dưới đây mô tả tình trạng của node:

"conditions": [
  {
    "type": "Ready",
    "status": "True",
    "reason": "KubeletReady",
    "message": "kubelet is posting ready status",
    "lastHeartbeatTime": "2019-06-05T18:38:35Z",
    "lastTransitionTime": "2019-06-05T11:41:27Z"
  }
]

Nếu trạng thái của Ready condition vẫn là Unknown hoặc False lâu hơn pod-eviction-timeout, thì một tham số được truyền đến kube-controller-manager và tất cả các Pod trên node được lên lịch để xóa bởi Node Controller. pod-eviction-timeout mặc định là năm phút. Trong một vài trường hợp khi node không thể truy cập thì apiserver sẽ không thể giao tiếp với kubelet trên node. Quyết định xóa các pod không thể được truyền đạt đến kubelet cho đến khi giao tiếp với apiserver được tái lập. Trong lúc đó, các pod đã được lên lịch để xóa vẫn có thể tiếp tục chạy trên nod được phân vùng.

Trong các version Kubernetes trước 1.5, node controller sẽ force delete các pod không thể truy cập đến từ apiserver. Tuy nhiên, từ version 1.5 và các version cao hơn, node controller không force delete các pod cho đến khi nó xác nhận rằng chúng đã ngừng chạy trong cluster. Bạn có thể thấy các pod có thể đang chạy trên một node không thể truy cập được như đang trong trạng thái Terminating hoặc Unknown. Trong các trường hợp Kubernetes không thể suy đoán ra một node đã vĩnh viễn rời khỏi cluster thì người quản trị cluster có thể cần xóa node bằng cách thủ công. Việc xóa đối tượng node từ Kubernetes dẫn đến việc tất cả Pod đang chạy trên node bị xóa khỏi apiserver và giải phóng tên của chúng.

Trong version 1.12, tính năng TaintNodesByCondition trở thành beta vì vậy node lifecycle controller tự động tạo các taint đại diện cho condition. Tương tự scheduler bỏ qua các điều kiện khi quyết định một node, thay vào đó nó chú ý vào các tainttoleration của Pod.

Capacity và Allocatable

Mô tả các tài nguyên sẵn có trong node: CPU, memory, và số lượng tối đa pod có thể được scheduled vào node.

Các trường trong capacity block chỉ ra tổng số tài nguyên mà một Node sở hữu. Allocatable block chỉ ra lượng tài nguyên trên Node đang sẵn sàng được tiêu thụ bởi các Pod thông thường.

Info

Thông tin chung về node như kernel version, Kubernetes version (kubeletkube-proxy version), Docker version (nếu sử dụng), tên của OS. Thông tin được thu thập bởi kubelet trong node.

Management

Không giống như podservice, một node vốn không được tạo ra bởi Kubernetes: nó được tạo bên ngoài bởi các cloud provider như GCE hoặc nó có sẵn trong nhóm các máy vật lý hoặc máy ảo của bạn. Vì vậy khi Kubernetes tạo một node, nó tạo một đối tượng đại diện cho node. Sau khi tạo, Kubernetes kiểm tra liệu node có hợp lệ hay không. Ví dụ, hãy thử tạo một node theo nội dung như sau:

{
  "kind": "Node",
  "apiVersion": "v1",
  "metadata": {
    "name": "10.240.79.157",
    "labels": {
      "name": "my-first-k8s-node"
    }
  }
}

Kubernetes tạo một node nội bộ (đại diện) và xác nhận node bằng cách kiểm tra tình trạng dựa trên trường metadata.name. Nếu node hợp lệ - tất cả service cần thiết đều chạy - nó sẽ đủ điều kiện để chạy một pod. Ngược lại, nó được bỏ qua trong bất kỳ hoạt động nào của cluster cho đến khi trở thành hợp lệ.

Hiện tại, có ba thành phần tương tác với Kubernetes node interface: node controller, kubeletkubectl.

Node Controller

Node controller là một master component quản lý nhiều khía cạnh khác nhau của các node.

Node controller có nhiều vai trò trong vòng đời của một node. Đầu tiên là việc gán CIDR block cho node khi nó được đăng ký (nếu CIDR assignment được bật).

Thứ hai là giữ danh sách các node nội bộ của node controller luôn cập nhật mới nhất theo danh sách các machine sẵn dùng của cloud provider. Khi chạy trong môi trường cloud, bất cứ khi nào một node trong tình trạng không tốt thì node controller sẽ hỏi cloud provider liệu VM cho node đó có sẵn dùng hay không. Nếu không, node controller xóa node đó khỏi danh sách các node của nó.

Thứ ba là theo dõi tình trạng của các node. Node controller chịu trách nhiệm cập nhật NodeReady condition của NodeStatus thành ConditionUnknown khi một node trở nên không thể truy cập đến (ví dụ như node bị down), và sau đó thu hồi tất cả pod khỏi node đó (sử dụng graceful termination) nếu node này tiếp tục không thể truy cập (timeout mặc định là 40s để khởi động báo cáo ConditionUnknown và 5 phút sau đó để khởi động quá trình thu hồi pod). Node controller liên tục kiểm tra trạng thái của mỗi node trong khoảng thời gian mỗi --node-monitor-period giây.

Trong các version Kubernetes trước 1.13, NodeStatus chính là tín hiệu theo dõi của node. Bắt đầu từ version 1.13, tính năng cho thuê node được giới thiệu là một tính năng alpha (NodeLease). Khi tính năng NodeLease được bật, mỗi node sẽ có một đối tượng Lease được liên kết trong namespace kube-node-lease, namespace được làm mới định kì; cả NodeStatusnode lease được coi như tín hiệu từ node. Các node lease được làm mới thường xuyên trong khi NodeStatus chỉ được báo cáo từ node đến master khi có các thay đổi hoặc đã đủ thời gian (mặc định là 1 phút, lâu hơn thời gian 40s timeout cho các node không thể truy cập). Vì node lease nhẹ hơn nhiều so với NodeStatus nên tính năng này làm cho tín hiệu của node trở nên rẻ hơn đáng kể từ cả hai khía cạnh là khả năng mở rộng và hiệu suất.

Từ version 1.4, node controller sẽ xem xét trạng thái của tất cả node trong cluster trước khi đưa ra quyết định thu hồi pod.

Trong nhiều trường hợp, node controller giới hạn tỉ lệ thu hồi thành --node-eviction-rate (mặc định là 0.1) mỗi giây, điều này nghĩa là nó sẽ không thu hồi pod từ hơn một node mỗi 10 giây.

Hành vi thu hồi thay đổi khi một node trong vùng khả dụng đã biết có tình trạng không tốt. Node controller kiểm tra tỉ lệ các node trong vùng tình trạng không tốt (NodeReady condition là ConditionUnknown hoặc ConditionFalse) ở trong cùng thời điểm. Nếu tỉ lệ các node trong tình trạng không tốt ít nhất là --unhealthy-zone-threshold (mặc định là 0.55) thì tỉ lệ thu hồi sẽ giảm xuống: nếu cluster nhỏ (ví dụ nhỏ hơn hoặc bằng --large-cluster-size-threshold node, mặc định 50) thì việc thu hồi bị dừng, mặt khác tỉ lệ thu hồi sẽ giảm xuống --secondary-node-eviction-rate (mặc định 0.01) mỗi giây. Lí do các chính sách này được triển khai trên mỗi vùng khả dụng là vì một vùng khả dụng có thể trở thành phân vùng từ master trong khi những các vùng còn lại vẫn được kết nối.

Từ version Kubernetes 1.6, NodeController cũng chịu trách nhiệm thu hồi các pod đang chạy trên node có các NoExecute taint, khi pod không tolerate các taint. Hơn nữa, vì là tính năng alpha nên mặc định nó bị disable, NodeContoller chịu trách nhiêm thêm các taint tương ứng với các vấn đề của node như node không thể truy cập hay không sẵn sàng (not ready).

Self-Registration

Khi flag kubelet --register-node được thiết lập là true (giá trị mặc định), kubelet sẽ cố gắng tự đăng ký với API server. Đây là mô hình được sử dụng bởi hầu hết các distro.

Để tự đăng ký, kubelet được khởi động với các option sau:

  • --kubeconfig - Đường dẫn đến các credential để tự xác thực đến apiserver.

  • --cloud-provider - cách giao tiếp với một cloud provider để đọc metadata của chính nó.

  • --register-node - tự động đăng ký với API server.

  • --register-with-taints - đăng ký node với dánh sách taint đã cung cấp (phân cách bằng dấu phảy <key>=<value>:<effect>). Không hoạt động nếu register-nodefalse.

  • --node-ip - địa chỉ IP của node.

  • --node-labels - nhãn của node khi đăng ký node trong cluster

  • --node-status-update-frequency - chỉ định tần suất kubelet gửi status của node đến master.

Khi Node authorization mode và NodeRestriction admission plugin được bật thì các kubelet chỉ được phép tạo và sửa đổi tài nguyên Node của riêng chúng

Manual Node Administration

Một quản trị viên cluster có thể tạo và sửa đổi các đối tượng node.

Nếu quản trị viên muốn tạo các đối tượng node theo cách thủ công thì cần thiết lập cờ kubelet--register-node=false.

Quản trị viên có thể sửa đổi các tài nguyên của node (kể cả thiết lập --register-node). Các sửa đổi bao gồm thiết lập các nhãn trên node và đánh dấu nó là unschedulable.

Nhãn của các node có thể được sử dụng kết hợp với các node selector trên các pod để điều khiển scheduling, ví dụ như ràng buộc một pod chỉ đủ điều kiện chạy trên một tập hợp con của các node.

Bằng cách đánh dấu một node là unschedulable sẽ ngăn chặn các pod mới được scheduled tới node đó, nhưng không áp dụng cho các pod đã tồn tại ở trên node. Việc này sẽ hữu ích vì nó như một bước chuẩn bị trước khi reboot node,... Ví dụ, để đánh dấu một node là unschedulable, ta cần chạy lệnh sau:

kubectl cordon $NODENAME

Node capacity

Capacity của một node (số lượng CPU và số lượng memory) là một thành phần của node. Thông thường, các node tự đăng ký và báo cáo capacity của chúng khi tạo node. Nếu bạn đang quản lý node theo cách thủ công thì bạn cần thiết lập node capacity khi thêm một node mới.

Kubernetes scheduler đảm bảo có đủ các tài nguyên cho tất cả pod trên một node. Nó kiểm tra tổng số request của các container trên node không lớn hơn node capacity. Điều này bao gồm tất cả container được khởi động bởi kubelet, nhưng không bao gồm các container được khởi động trực tiếp bới container runtime cũng như bất kỳ tiến trình nào chạy bên ngoài các container.

26 tháng 5, 2019

Giới thiệu về Kubernetes

Giới thiệu về Kubernetes (k8s)

Trong bài viết này, chúng ta sẽ cùng tìm hiểu Kubernetes (k8s) là gì, nó không phải là gì, nó giải quyết vấn đề gì, các thành phần kiến ​​trúc của nó, làm thế nào để chạy trên local và cuối cùng là một số lựa chọn thay thế trên thị trường.

Vậy Kubernetes là gì?

Để bắt đầu hiểu tính hữu dụng của Kubernetes, trước tiên chúng ta phải hiểu hai khái niệm: hạ tầng bất biếncontainer. Hạ tầng bất biến là khái niệm mà các máy chủ không bao giờ được sửa đổi sau khi đã triển khai. Ý tưởng là nếu cần phải thay đổi một cái gì đó, thì nó không bao giờ được sửa đổi trực tiếp trên máy chủ, thay vào đó một máy chủ mới được xây dựng từ một base-image với tất cả các thay đổi cần thiết để chúng ta có thể thay thế máy chủ cũ bằng máy chủ mới mà không cần bất kỳ sửa đổi thêm nào. Container là một cách để đóng gói code của bạn, runtime, các system tool, các thư viện hệ thống và cấu hình để nó có thể được vận chuyển dưới dạng thực thi độc lập và nhẹ. Ý tưởng là ứng dụng của bạn sẽ hoạt động giống nhau ở mọi nơi, mọi lúc (ví dụ Ubuntu hoặc Windows). Điều đáng nói là container hóa không phải là một khái niệm mới, nó chỉ thực sự được phổ biến với sự phát triển của microservice và Docker.

Bây giờ chúng ta đã hiểu hai khái niệm đó, vậy Kubernetes là gì? Tôi sẽ định nghĩa nó như là container hoặc một nền tảng microservice điều phối computing, network và quản lý công việc cho chúng ta. Kubernetes mở rộng các cách ta scale ứng dụng được đóng gói, vì vậy chúng ta có thể nhận được tất cả những lợi ích của một kiến trúc hạ tầng bất biến

Từ Kubernetes có ý nghĩa gì, đó là một từ trong tiếng Hy Lạp, có nghĩa là người lái xe hoặc phi công và K8S là một từ viết tắt bằng cách thay thế 8 chữ cái "ubernete" bằng số "8".

Kubernetes cung cấp những gì?

K8s cung cấp một số tính năng chính giúp quy mô ứng dụng được đóng gói của bạn hoạt động hiệu quả.

  • Horizontal scaling - dễ dàng scale-up ứng dụng từ command-line hoặc từ giao diện UI

  • Tự động tráo đổi và rollback - theo dõi tình trạng của ứng dụng của bạn để đảm bảo tất cả các instance không bị lỗi cùng lúc. Nếu có sự cố, k8s sẽ tự động rollback thay đổi.

  • Service discovery và load balancing - Các container sẽ có IP riêng và bạn có thể đặt một tập hợp các container phía sau một tên miền DNS duy nhất để cân bằng tải.

  • Storage orchestration - tự động mount local, public-cloud hoặc một network storage.

  • Quản lý Secret và Cấu hình - Tạo và cập nhật các secret và cấu hình mà không cần phải build lại image của bạn.

  • Tự phục hồi - Khởi động lại các container khi không thành công, thay thế và lập lịch lại các container khi các node ngừng hoạt động, kill các container không đáp ứng được health-check do người dùng định nghĩa và không chúng phục vụ client cho đến khi chúng thực sự sẵn sàng.

  • Batch execution - Quản lý batch và tích hợp liên tục lượng công việc. Nó cũng đảm bảo việc thay thế các container bị lỗi

  • Tự động đóng gói - k8s đủ thông minh để lên lịch cho các container dựa trên yêu cầu tài nguyên và các ràng buộc khác.

Như bạn có thể thấy, Kubernetes cung cấp cho chúng ta rất nhiều thứ. Nó cho phép ta khởi chạy một cơ sở hạ tầng bất biến thực sự, nơi mà ứng dụng container của chúng ta có thể bị kill và tự phục hồi, container mới này có quyền truy cập vào tất cả các volume, secret, các cấu hình,... nó cần, tất cả đều là tự động.

Điều khoản và định nghĩa cơ bản của Kubernetes

Để bắt đầu hiểu cách sử dụng k8s, trước tiên chúng ta phải hiểu các đối tượng trong API. Có các đối tượng k8s cơ bản và một vài higher-level abstraction được gọi là các Controller.

Các đối tượng cơ bản

  • Pod - một nhóm gồm một hoặc nhiều container

  • Service - chuyển tiếp các request đến một nhóm các Pod

  • Volume - một thư mục có thể truy cập vào các container trong một Pod

  • Namespace - cho phép chúng ta tách cluster để dành riêng cho mục đích nhất định, ví dụ như một dự án hoặc một team phát triển

Higher-Level Abstractions

  • ReplicaSet - đảm bảo rằng số lượng Pod chúng ta muốn là những gì đang chạy.

  • Deployment - cung cấp các tham số cập nhật cho các PodReplicaSet

  • StatefulSet - đối tượng được sử dụng để quản lý các ứng dụng có trạng thái như các cơ sở dữ liệu.

  • DaemonSet - đảm bảo rằng tất cả hoặc một số node worker chạy một bản sao của một Pod. Điều này rất hữu ích cho các ứng dụng daemon như fluentd.

  • Job - tạo một hoặc nhiều Pod và thực thi một hoặc nhiều tác vụ nhất định, sau đó xóa các Pod.

Kiến trúc & Thành phần Kubernetes

Một k8s cluster được tạo thành từ một node Master. Node Master này có nhiệm vụ expose API, lên lịch triển khai và quản lý cluster nói chung. Các node Worker chịu trách nhiệm về container-runtime (giống như Docker hoặc rkt), cùng với một agent để giao tiếp với Master.

Các thành phần Master:

  • Kube-apiserver - expose API.

  • Etcd - Một key-value store lưu trữ tất cả dữ liệu cluster.

  • Kube-scheduler - lập lịch các pod mới trên các node worker

  • Kube-controller-manager - khởi chạy các controller

  • Cloud-controller-manager - giao tiếp với các clould-provider

Những thành phần này tạo nên một node Master. Etcd có thể được chạy trên cùng một server như một node Master hoặc trên một cluster chuyên dụng.

Các thành phần node:

  • Kubelet - Một tác nhân đảm bảo các container đang chạy trong một pod.

  • Kube-proxy - tuân theo các network-rule và thực hiện chuyển tiếp.

  • Container Runtime - Chịu trách nhiệm chạy container.

Dưới đây là một sơ đồ kiến ​​trúc cơ bản.

Hình 1

Cách cài đặt Kubernetes

Cài đặt k8s local rất đơn giản và dễ hiểu. Bạn sẽ phải cần hai công cụ sau: KubectlMinikube.

  • Kubectl - một CLI được sử dụng để tương tác với cluster.

  • Minikube - đây là file binary giúp triển khai một k8s cluster local trên máy phát triển.

Với hai công cụ này, bạn có thể bắt đầu triển khai các ứng dụng đã được container hóa của mình lên k8s cluster local chỉ trong vòng vài phút. Đối với một cluster-production đáp ứng tính sẵn sàng cao, bạn có thể sử dụng các công cụ như Kops, EKS, đây là service được cung cấp bởi AWS hoặc như GKE được cung cấp bởi Google.

Kubernetes sẽ không làm cho bạn những việc gì?

  • Nó không giới hạn loại ứng dụng bạn có thể triển khai. Nó cho phép bất kỳ loại ứng dụng được viết bằng bất kỳ ngôn ngữ lập trình nào. Miễn là nó được đặt trong một container.

  • Nó không thay thế các công cụ giống như Jenkins vì vậy nó sẽ không build ứng dụng của bạn cho bạn.

  • Nó không phải là một middleware nên nó sẽ không thực hiện các tác vụ mà middleware thực hiện như các message-bus hoặc caching quen thuộc.

  • Không quan tâm giải pháp ghi log nào được sử dụng. Hãy thiết lập ứng dụng của bạn ghi log ra stdout, và sau đó bạn có thể thu thập những log bạn muốn.

  • Không quan tâm về ngôn ngữ cấu hình của bạn (ví dụ: json).

K8s không quan tâm đến những điều này chỉ đơn giản là nó cho phép chúng ta xây dựng ứng dụng theo cách chúng ta muốn, phơi bày và thu thập bất kỳ loại thông tin nào theo cách chúng ta muốn.

Các đối thủ cạnh tranh Kubernetes

Có các công cụ khác tương tự như k8s. Ví dụ: có Docker Compose cho staging nhưng không thích hợp cho production hoặc Nomad cho phép chúng ta quản lý cluster và lên lịch nhưng nó không giải quyết được nhu cầu quản lý và giám sát cấu hình, secret của chúng ta, Netflix chỉ mở mã nguồn nền tảng điều phối (orchestration) của họ được gọi là Titus do đó không có đủ người sử dụng nó trong production, ngoại trừ Netflix. Nhìn chung, k8s cung cấp các tính năng tốt nhất hiện có với các dự án bổ trợ của bên thứ 3 để mở rộng chức năng của nó.

view raw Kubernetes.md hosted with ❤ by GitHub
z_img_001.png
view raw z_img_001.png hosted with ❤ by GitHub

26 tháng 3, 2019

Chiến lược phân nhánh và quản lý release khi sử dụng git

Chiến lược phân nhánh và quản lý release khi sử dụng git

Trong bài viết này, tôi sẽ không đề cập chi tiết đến bất kỳ một dự án cụ thể nào, ở đây chỉ đơn thuần là nói về chiến lược phân nhánh và quản lý release.

Hình 1

Hình 1

Tại sao lại sử dụng git?

Để thảo luận kỹ về ưu và nhược điểm của Git so với các hệ thống kiểm soát mã nguồn tập trung, hãy xem tại web, có rất nhiều cuộc tranh cãi về vấn đề này. Là một nhà phát triển, tôi thích Git hơn tất cả các công cụ khác hiện nay. Git thực sự đã thay đổi cách nghĩ của các nhà phát triển về việc merge và phân nhánh.

Với Git, những hành động này cực kỳ đơn giản, và chúng thực sự được coi là một trong những phần cốt lõi trong quy trình làm việc hàng ngày của bạn.

Như một hệ quả của sự đơn giản và lặp đi lặp lại của nó, việc phân nhánh và merge không còn là điều đáng sợ (so với CVS/SVN).

Giờ hãy cùng xem qua mô hình phát triển. Mô hình mà tôi sắp trình bày ở đây về cơ bản không khác gì một bộ quy trình mà mọi thành viên trong nhóm phát triển bắt buộc phải tuân theo khi quản lý phát triển phần mềm.

Phân cấp nhưng tập trung

Thiết lập repo mà chúng tôi sử dụng và hoạt động tốt với mô hình phân nhánh này, đó là với một repo trung tâm. Lưu ý rằng repo này chỉ được coi là một trung tâm (vì Git là một DVCS, không có thứ gọi là repo trung tâm ở cấp độ kỹ thuật). Chúng tôi sẽ đề cập đến repo này như là origin, tên này quen thuộc với tất cả người dùng Git.

Hình 2

Hình 2

Các nhà phát triển thực hiện pullpush origin. Nhưng bên cạnh các mối quan hệ pull/push tập trung, mỗi nhà phát triển cũng có thể thực hiện lệnh pull các thay đổi từ các đồng nghiệp khác để tạo thành các nhóm phụ. Ví dụ, điều này có thể hữu ích khi làm việc cùng với hai hoặc nhiều nhà phát triển trên một tính năng mới, trước khi push lên origin. Trong hình trên, có các phần phụ của Alice và Bob, Alice và David, và Clair và David.

Về mặt kỹ thuật, điều này có nghĩa là Alice đã xác định một Git remote, được đặt tên bob, trỏ đến repo của Bob và ngược lại.

Các nhánh chính

Mô hình phát triển được lấy cảm hứng rất nhiều từ các mô hình hiện nay đang có. Repo trung tâm giữ hai nhánh chính sẽ tồn tại xuyên suốt dự án:

  • master
  • develop

Mọi người sử dụng Git đều cần phải hiểu về nhánh master tại origin. Song song với nhánh master, ta cũng nên cần một nhánh khác gọi là develop.

Hình 3

Hình 3

Chúng tôi quyết định origin/master là chi nhánh chính, đây là nhánh mà mã nguồn HEAD luôn phản ánh trạng thái có thể sẵn sàng đưa lên production.

Chúng tôi coi origin/develop là nhánh chính của mã nguồn, đây là nhánh mà HEAD phải luôn phản ánh trạng thái phát triển mới nhất cho lần release tiếp theo. Một số người sẽ gọi đây là integration branch (nhánh tích hợp) - nhánh này sẽ là nhánh để cơ sở để tạo ra các bản nightly-build.

Khi mã nguồn trong nhánh develop đã đạt đến ngưỡng ổn định và sẵn sàng để được phát hành, tất cả các thay đổi sẽ được merge trở lại vào nhánh master và sau đó được gắn tag với số vesion tương ứng.

Do đó, mỗi khi có bất kỳ thay đổi nào được merge vào nhánh master, theo định nghĩa thì đây được coi như là một bản phát hành sản phẩm. Chúng tôi sử dụng một Git hook script để build tự động và đưa phần mềm đến các server production mỗi khi có một commit mới trên master.

Phân nhánh

Bên cạnh các nhánh chính masterdevelop, mô hình phát triển của chúng tôi sử dụng nhiều nhánh hỗ trợ để hỗ trợ phát triển song song giữa các thành viên trong nhóm, dễ dàng theo dõi các tính năng, chuẩn bị phát hành sản xuất và hỗ trợ khắc phục nhanh các sự cố sản xuất trực tiếp. Không giống như các nhánh chính, các nhánh này luôn có thời gian tồn tại giới hạn, vì cuối cùng chúng cũng sẽ bị loại bỏ.

Có nhiều kiểu branch mà ta có thể sử dụng:

  • Feature branch
  • Release branch
  • Hotfix branch

Mỗi branch này dều có một mục đích cụ thể và bị ràng buộc với các quy tắc nghiêm ngặt về việc xác định nhánh nào là nhánh gốc, và nhánh nào là nhánh đích để merge. Các nhánh được phân loại theo cách chúng tôi sử dụng chúng.

Các nhánh feature

Có thể phân nhánh từ: develop

Phải được merge trở lại vào: develop

Quy ước đặt tên branch: tên bất kì nhưng ngoại trừ master, develop, release-*, hoặc hotfix-*

Hình 4

Hình 4

Các nhánh feature (hoặc đôi khi được gọi là nhánh chủ đề) được sử dụng để phát triển các tính năng mới cho bản phát hành sắp tới hoặc trong tương lai xa. Khi bắt đầu phát triển một tính năng, bản phát hành mục tiêu trong đó tính năng này sẽ được kết hợp có thể chưa được biết đến vào thời điểm đó. Bản chất của nhánh feature là nó tồn tại miễn là tính năng này đang được phát triển, nhưng cuối cùng sẽ được merge trở lại develop (để chắc chắn thêm tính năng mới vào bản phát hành sắp tới) hoặc bị loại bỏ (trong trường hợp thử nghiệm đáng thất vọng).

Các nhánh feature thường tồn tại trong các repo của nhà phát triển, không phải trong origin.

Tạo một nhánh feature

Khi bắt đầu làm việc trên một tính năng mới, hãy tạo một nhánh mới từ nhánh develop.

$ git checkout -b myfeature develop
Switched to a new branch "myfeature"

Tích hợp nhánh feature vào develop

Các tính năng đã hoàn thành, ta có thể merge vào nhánh develop, để có thể thêm những tính năng mới này vào bản phát hành sắp tới:

$ git checkout develop
Switched to branch 'develop'
$ git merge --no-ff myfeature
Updating ea1b82a..05e9557
(Summary of changes)
$ git branch -d myfeature
Deleted branch myfeature (was 05e9557).
$ git push origin develop

Thực hiện merge mà có sử dụng cờ --no-ff sẽ luôn tạo ra một commit merge, ngay cả khi việc merge có thể được thực hiện với fast-forward. Điều này sẽ làm tránh mất thông tin lịch sử về sự tồn tại của một nhánh feature và một nhóm tất cả các commit phát triển tính năng này. Hãy cùng so sánh:

Hình 5

Hình 5

Trong trường hợp thứ hai, chúng ta khi nhìn vào lịch sử git sẽ không thể thấy được các đối tượng commit cùng thực hiện một tính năng, chúng ta sẽ buộc phải đọc tất cả các git message để có thể hiểu được cả quá trình phát triển. Việc revert toàn bộ một tính năng (tức là một nhóm các commit), thực sự là một vấn đề đau đầu trong trường hợp thứ hai, trong khi nó được thực hiện rất dễ dàng nếu có sử dụng cờ --no-ff khi merge.

Tuy việc sử dụng cờ --no-ff sẽ tạo ra nhiều commit (empty) hơn, nhưng lợi ích mà nó mang lại thực sự rất lớn.

Các nhánh release

Có thể phân nhánh từ: develop

Phải merge trở lại vào: developmaster

Quy ước đặt tên branch: release-*

Các nhánh release hỗ trợ việc phát hành một phiên bản production mới. Chúng cho phép sửa lỗi nhỏ và chuẩn bị meta-data để phát hành (số phiên bản, ngày build, v.v.). Bằng cách thực hiện tất cả các công việc này trên một nhánh release, nhánh develop sẽ được dọn dẹp cho mục đích thực hiện các tính năng mới đợt big-release tiếp theo.

Thời điểm quan trọng để phân nhánh nhánh release mới từ develop là khi phát triển (gần như) phản ánh trạng thái mong muốn của bản phát hành mới. Ít nhất là tất cả các tính năng được nhắm đến cho bản phát hành đã được phát triển phải được merge vào develop ở thời điểm này.

Chính xác là khi khởi động một nhánh release, phiên bản sắp tới sẽ được gán một version-number, không trùng với phiên bản nào trước đó. Cho đến thời điểm đó, nhánh develop đã phản ánh những thay đổi cho bản phát hành tiếp theo, nhưng sẽ không rõ liệu phát hành tiếp theo sẽ là 0.3 hay 1.0. Quyết định đó được đưa ra khi khởi động nhánh release và được thực hiện theo các quy tắc của dự án về việc đánh số phiên bản.

Tạo một nhánh release

Chi nhánh release được tạo ra từ nhánh develop. Ví dụ: giả sử phiên bản 1.1.5 là bản phát hành sản xuất hiện tại và chúng tôi sẽ có một bản phát hành lớn sắp tới. Trạng thái develop đã sẵn sàng cho phiên bản phát hành tiếp theo của Wap và chúng tôi đã quyết định rằng phiên bản này sẽ trở thành phiên bản 1.2 (thay vì 1.1.6 hoặc 2.0). Vì vậy, chúng tôi rẽ nhánh và đặt tên cho nhánh release phản ánh số phiên bản mới:

$ git checkout -b release-1.2 develop
Switched to a new branch "release-1.2"
$ ./bump-version.sh 1.2
Files modified successfully, version bumped to 1.2.
$ git commit -a -m "Bumped version number to 1.2"
[release-1.2 74d9424] Bumped version number to 1.2
1 files changed, 1 insertions(+), 1 deletions(-)

Sau khi tạo một nhánh mới và chuyển sang nó, chúng tôi tăng số phiên bản. Ở đây, bump-version.sh là một tập lệnh shell hư cấu thay đổi một số tệp trong bản sao làm việc để phản ánh phiên bản mới. (Tất nhiên đây có thể là một thay đổi thủ công. Điểm quan trọng là một số tệp thay đổi.) Sau đó, số phiên bản bị lỗi được commit.

Nhánh mới này có thể tồn tại ở đó trong một thời gian đến khi việc phát hành có thể chắc chắn được triển khai. Trong thời gian đó, việc sửa lỗi có thể được áp dụng trong nhánh này (chứ không phải trên nhánh develop). Việc thêm các tính năng mới lớn ở đây sẽ bị nghiêm cấm. Chúng phải được merge vào develop, và do đó phải đợi release ở phiên bản lớn tiếp theo.

Hoàn thành release branch

Khi trạng thái của nhánh release đã sẵn sàng để trở thành một bản phát hành thực sự, ta cần thực hiện một số công việc nhất định:

  • Đầu tiên, nhánh release phải được merge vào master (vì theo định nghĩa mỗi commit trên master là một bản phát hành mới).

  • Commit trên master phải được gắn tag để dễ dàng tham chiếu lịch sử trong tương lai.

  • Cuối cùng, các thay đổi được thực hiện trên nhánh release cần được merge trở lại vào nhánh develop, để các bản phát hành trong tương lai cũng chứa các bản sửa lỗi này.

Hai bước đầu tiên trong Git:

$ git checkout master
Switched to branch 'master'
$ git merge --no-ff release-1.2
Merge made by recursive.
(Summary of changes)
$ git tag -a 1.2

Việc phát hành hiện đã được thực hiện và được gắn tag để tham khảo trong tương lai.

Note: Bạn cũng có thể muốn sử dụng cờ -s hoặc -u <key> để ký tag của bạn bằng mật mã.

Để giữ những thay đổi được thực hiện trong nhánh release, chúng ta cần merge những thay đổi đó vào develop. Trong Git:

$ git checkout develop
Switched to branch 'develop'
$ git merge --no-ff release-1.2
Merge made by recursive.
(Summary of changes)

Bước này cũng có thể dẫn đến xung đột merge (có thể là do chúng tôi đã thay đổi số phiên bản). Nếu vậy, sửa chữa nó và commit.

Bây giờ chúng tôi đã thực sự hoàn thành và nhánh release có thể bị xóa, vì chúng tôi không cần nó nữa:

$ git branch -d release-1.2
Deleted branch release-1.2 (was ff452fe).

Các nhánh hotfix

Nhánh có thể tắt từ: master

Phải merge trở lại vào: developmaster

Quy ước đặt tên chi nhánh: hotfix-*

Các nhánh hotfix rất giống với các nhánh release ở chỗ chúng cũng có nghĩa là để chuẩn bị cho một bản release-production mới, mặc dù không có kế hoạch. Họ phát sinh từ sự cần thiết phải hành động ngay lập tức với trạng thái không mong muốn của phiên bản sản xuất trực tiếp. Khi một lỗi nghiêm trọng trong phiên bản production phải được khắc phục ngay lập tức, một nhánh hotfix có thể được phân nhánh từ tag tương ứng trên nhánh chính mà có đánh dấu phiên bản production.

Hình 6

Hình 6

Bản chất là công việc của các thành viên trong nhóm (trên nhánh develop) vẫn có thể tiếp tục, trong khi một người khác đang chuẩn bị một bản hotfix cho môi trường production

Creating the hotfix branch

Các nhánh hotfix được tạo từ nhánh master. Ví dụ: giả sử phiên bản 1.2 là bản release-production hiện tại đang chạy trực tiếp và gây rắc rối do lỗi nghiêm trọng. Nhưng những thay đổi trên develop vẫn chưa thực sự ổn định. Thì chúng ta có thể tạo một nhánh hotfix và bắt đầu khắc phục sự cố:

$ git checkout -b hotfix-1.2.1 master
Switched to a new branch "hotfix-1.2.1"
$ ./bump-version.sh 1.2.1
Files modified successfully, version bumped to 1.2.1.
$ git commit -a -m "Bumped version number to 1.2.1"
[hotfix-1.2.1 41e61bb] Bumped version number to 1.2.1
1 files changed, 1 insertions(+), 1 deletions(-)

Đừng quên tăng số phiên bản sau khi phân nhánh!

Sau đó, sửa lỗi và commit sửa lỗi trong một hoặc nhiều commit riêng biệt.

$ git commit -m "Fixed severe production problem"
[hotfix-1.2.1 abbe5d6] Fixed severe production problem
5 files changed, 32 insertions(+), 17 deletions(-)

Kết thúc một nhánh hotfix

Khi kết thúc, lỗi cần phải được merge trở lại master, nhưng cũng cần được merge trở lại develop, để bảo vệ rằng lỗi cũng được bao gồm trong bản phát hành tiếp theo. Điều này hoàn toàn tương tự như cách các nhánh release được hoàn thành.

Đầu tiên, cập nhật master và gắn tag release.

$ git checkout master
Switched to branch 'master'
$ git merge --no-ff hotfix-1.2.1
Merge made by recursive.
(Summary of changes)
$ git tag -a 1.2.1

Note: Bạn cũng có thể muốn sử dụng cờ -s hoặc -u <key> để ký thẻ của bạn bằng mật mã.

Tiếp theo, bao gồm cả lỗi trong develop:

$ git checkout develop
Switched to branch 'develop'
$ git merge --no-ff hotfix-1.2.1
Merge made by recursive.
(Summary of changes)

Một ngoại lệ cho quy tắc ở đây là, khi nhánh release hiện đang tồn tại, các thay đổi hotfix cần được merge vào nhánh release đó, thay vì develop. Bugfix được merge vào nhánh release cuối cùng cũng sẽ được merge vào develop khi nhánh release kết thúc. (Lưu ý là nếu công việc develop yêu cầu ngay lập tức cần sửa lỗi này và không thể đợi đến khi nhánh release kết thúc, bạn cũng có thể merge bugfix đó vào nhánh develop lúc này)

Cuối cùng là xóa nhánh hotfix khi đã được merge:

$ git branch -d hotfix-1.2.1
Deleted branch hotfix-1.2.1 (was abbe5d6).

24 tháng 2, 2019

Package context trong Go

Go Concurrency Patterns: Context

Introduction

Trong Go, mỗi request đến sẽ được handle trong một goroutine. Các request-handler thường bổ sung thêm các goroutine để truy cập đến backend như database và các RPC service.

Context

// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

Method Done trả về một channel được sử dụng như một tín hiệu hủy cho các function đại diện cho Context: khi một channel close, các function nên hủy bỏ công việc đang thực thi và return. Method Err trả về một error chỉ ra lý do mà Context bị hủy.

Lý do mà Context không có method Cancel cũng tương tự như channel Done chỉ là một channel nhận kết quả (receive-only): hàm mà nhận tín hiệu cancel thông thường sẽ không phải là một trong các hàm gửi tín hiệu (signal) đi. Đặc biệt, khi một operation cha khởi chạy goroutine cho các sub-operation, những sub-operation này sẽ không thể hủy operation cha. Thay vào đó, hàm WithCancel có cung cấp cách để hủy một Context mới.

Context có thể được dùng đồng thời bởi nhiều goroutine. Chúng ta có thể truyền một Context cho goroutine bất kì và có thể hủy Context để báo hiệu cho tất cả các goroutine chứa Context này.

Method Deadline cho phép các function xác định xem liệu chúng có nên thực thi công việc hay không. Nếu thời gian còn lại quá ngắn thì nó có thể không đáng để thực thi. Ta cũng có thể sử dụng một thời hạn (deadline) để thực hiện thiết lập một timeout cho các giao tiếp I/O.

Method Value cho phép một Context mang theo dữ liệu thuộc phạm vi request (request-scope). Dữ liệu này bắt buộc phải là safe khi được sử dụng đồng thời bời nhiều goroutine.

Derived contexts

Package context cung cấp các hàm dẫn xuất một giá trị Context mới từ một Context đã tồn tại, chúng sẽ tạo thành dạng cây (Context-tree): khi một Context bị cancel, tất cả Context đã được dẫn xuất từ nó cũng sẽ bị cancel.

Background sẽ là root của các Context-tree, nó không bao giờ bị cancel:

// Background returns an empty Context. It is never canceled, has no deadline,
// and has no values. Background is typically used in main, init, and tests,
// and as the top-level Context for incoming requests.
func Background() Context

Các hàm WithCancelWithTimeout trả về giá trị Context dẫn xuất có thể bị cancel trước Context cha. Context liên kết với một request đến thường bị cancel khi handler của request này return. Hàm WithCancel cũng hữu ích để cancel các request dư thừa khi sử dụng nhiều replica. Hàm WithTimeout thì có thể giúp thiết lập một deadline cho các request đến các server backend:

// WithCancel returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed or cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// A CancelFunc cancels a Context.
type CancelFunc func()

// WithTimeout returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed, cancel is called, or timeout elapses. The new
// Context's Deadline is the sooner of now+timeout and the parent's deadline, if
// any. If the timer is still running, the cancel function releases its
// resources.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue cung cấp cách liên kết các giá trị request-scope với một Context:

// WithValue returns a copy of parent whose Value method returns val for key.
func WithValue(parent Context, key interface{}, val interface{}) Context

Cách tốt nhất để nắm được cách sử dụng package context là tham khảo qua các ví dụ thực tế.

Ví dụ: Google Web Search

Ví dụ của chúng ta là một server HTTP handle các URL /search?q=golang&timeout=1s bằng việc forward từ khóa truy vấn "golang" đến Google Web Search API và hiển thị ra kết quả. Tham số timeout báo cho server biết khoảng thời gian để cancel request.

Code được chia thành 3 package:

  • server cung cấp hàm main và handler cho /search.
  • userip cung cấp các hàm để trích xuất địa chỉ IP của người dùng từ request và liên kết nó với một Context.
  • google cung cấp hàm Search để gửi truy vấn đến cho Google.

Server

Ở đây server sẽ xử lý các request có format giống như search?q=golang bằng cách cung cấp một vài kết quả đầu tiên khi tìm kiếm bằng Google. Ta sẽ sử dụng hàm handleSearch để handle endpoint /search. Handler này khởi tạo một Context gọi là ctx và bố trí cancel khi handler return. Nếu request đến bao gồm tham số timeout trong URL thì Context sẽ bị cancel tự động nếu như hết thời gian timeout:

func handleSearch(w http.ResponseWriter, req *http.Request) {
    // ctx is the Context for this handler. Calling cancel closes the
    // ctx.Done channel, which is the cancellation signal for requests
    // started by this handler.
    var (
        ctx    context.Context
        cancel context.CancelFunc
    )
    timeout, err := time.ParseDuration(req.FormValue("timeout"))
    if err == nil {
        // The request has a timeout, so create a context that is
        // canceled automatically when the timeout expires.
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    } else {
        ctx, cancel = context.WithCancel(context.Background())
    }
    defer cancel() // Cancel ctx as soon as handleSearch returns.

Handler này trích xuất từ khóa truy vấn từ request và địa chỉ IP của client bằng cách sử dụng package userip. Backend request cần đến địa chỉ IP của client vì vậy hàm handleSearch đính kèm nó vào trong ctx:

    // Check the search query.
    query := req.FormValue("q")
    if query == "" {
        http.Error(w, "no query", http.StatusBadRequest)
        return
    }

    // Store the user IP in ctx for use by code in other packages.
    userIP, err := userip.FromRequest(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    ctx = userip.NewContext(ctx, userIP)

Handler gọi đến google.Search sử dụng ctxquery:

    // Run the Google search and print the results.
    start := time.Now()
    results, err := google.Search(ctx, query)
    elapsed := time.Since(start)

Nếu tìm kiếm thành công, handler này sẽ hiển thị ra kết quả:

    if err := resultsTemplate.Execute(w, struct {
        Results          google.Results
        Timeout, Elapsed time.Duration
    }{
        Results: results,
        Timeout: timeout,
        Elapsed: elapsed,
    }); err != nil {
        log.Print(err)
        return
    }

Package userip

Package userip cung cấp các function để trích xuất địa chỉ IP người dùng từ request gửi lên và liên kết nó với một Context. Một Context chứa một đối tượng map với keyvalue đều là kiểu dữ liệu interface{}. Kiểu dữ liệu của key phải hỗ trợ so sánh equal, và value phải an toàn khi được sử dụng đồng thời bời nhiều goroutine. Các package giống như userip ẩn đi chi tiết ánh xạ key-value và cung cấp khả năng truy cập vào một value cụ thể trong Context.

Để tránh trùng lặp kiểu dữ liệu key, userip định nghĩa ra một kiểu unexportedkey và sử dụng một value của kiểu dữ liệu này như một context-key:

// The key type is unexported to prevent collisions with context keys defined in
// other packages.
type key int

// userIPkey is the context key for the user IP address.  Its value of zero is
// arbitrary.  If this package defined other context keys, they would have
// different integer values.
const userIPKey key = 0

FromRequest trích xuất một giá trị userIP từ một http.Request:

func FromRequest(req *http.Request) (net.IP, error) {
    ip, _, err := net.SplitHostPort(req.RemoteAddr)
    if err != nil {
        return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
    }
    ...
}

NewContext khởi tạo một đối tượng Context mới mang giá trị userIP đã được cấp:

func NewContext(ctx context.Context, userIP net.IP) context.Context {
    return context.WithValue(ctx, userIPKey, userIP)
}

FromContext trích xuất một userIP từ một Context:

func FromContext(ctx context.Context) (net.IP, bool) {
    // ctx.Value returns nil if ctx has no value for the key;
    // the net.IP type assertion returns ok=false for nil.
    userIP, ok := ctx.Value(userIPKey).(net.IP)
    return userIP, ok
}
// Package google provides a function to do Google searches using the Google Web
// Search API. See https://developers.google.com/web-search/docs/
//
// This package is an example to accompany https://blog.golang.org/context.
// It is not intended for use by others.
//
// Google has since disabled its search API,
// and so this package is no longer useful.
package google
import (
"context"
"encoding/json"
"net/http"
"golang.org/x/blog/content/context/userip"
)
// Results is an ordered list of search results.
type Results []Result
// A Result contains the title and URL of a search result.
type Result struct {
Title, URL string
}
// Search sends query to Google search and returns the results.
func Search(ctx context.Context, query string) (Results, error) {
// Prepare the Google Search API request.
req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Set("q", query)
// If ctx is carrying the user IP address, forward it to the server.
// Google APIs use the user IP to distinguish server-initiated requests
// from end-user requests.
if userIP, ok := userip.FromContext(ctx); ok {
q.Set("userip", userIP.String())
}
req.URL.RawQuery = q.Encode()
// Issue the HTTP request and handle the response. The httpDo function
// cancels the request if ctx.Done is closed.
var results Results
err = httpDo(ctx, req, func(resp *http.Response, err error) error {
if err != nil {
return err
}
defer resp.Body.Close()
// Parse the JSON search result.
// https://developers.google.com/web-search/docs/#fonje
var data struct {
ResponseData struct {
Results []struct {
TitleNoFormatting string
URL string
}
}
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return err
}
for _, res := range data.ResponseData.Results {
results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
}
return nil
})
// httpDo waits for the closure we provided to return, so it's safe to
// read results here.
return results, err
}
// httpDo issues the HTTP request and calls f with the response. If ctx.Done is
// closed while the request or f is running, httpDo cancels the request, waits
// for f to exit, and returns ctx.Err. Otherwise, httpDo returns f's error.
func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
// Run the HTTP request in a goroutine and pass the response to f.
c := make(chan error, 1)
req = req.WithContext(ctx)
go func() { c <- f(http.DefaultClient.Do(req)) }()
select {
case <-ctx.Done():
<-c // Wait for f to return.
return ctx.Err()
case err := <-c:
return err
}
}
view raw google.go hosted with ❤ by GitHub
// The server program issues Google search requests and demonstrates the use of
// the go.net Context API. It serves on port 8080.
//
// The /search endpoint accepts these query params:
// q=the Google search query
// timeout=a timeout for the request, in time.Duration format
//
// For example, http://localhost:8080/search?q=golang&timeout=1s serves the
// first few Google search results for "golang" or a "deadline exceeded" error
// if the timeout expires.
package main
import (
"context"
"html/template"
"log"
"net/http"
"time"
"golang.org/x/blog/content/context/google"
"golang.org/x/blog/content/context/userip"
)
func main() {
http.HandleFunc("/search", handleSearch)
log.Fatal(http.ListenAndServe(":8080", nil))
}
// handleSearch handles URLs like /search?q=golang&timeout=1s by forwarding the
// query to google.Search. If the query param includes timeout, the search is
// canceled after that duration elapses.
func handleSearch(w http.ResponseWriter, req *http.Request) {
// ctx is the Context for this handler. Calling cancel closes the
// ctx.Done channel, which is the cancellation signal for requests
// started by this handler.
var (
ctx context.Context
cancel context.CancelFunc
)
timeout, err := time.ParseDuration(req.FormValue("timeout"))
if err == nil {
// The request has a timeout, so create a context that is
// canceled automatically when the timeout expires.
ctx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
defer cancel() // Cancel ctx as soon as handleSearch returns.
// Check the search query.
query := req.FormValue("q")
if query == "" {
http.Error(w, "no query", http.StatusBadRequest)
return
}
// Store the user IP in ctx for use by code in other packages.
userIP, err := userip.FromRequest(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx = userip.NewContext(ctx, userIP)
// Run the Google search and print the results.
start := time.Now()
results, err := google.Search(ctx, query)
elapsed := time.Since(start)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := resultsTemplate.Execute(w, struct {
Results google.Results
Timeout, Elapsed time.Duration
}{
Results: results,
Timeout: timeout,
Elapsed: elapsed,
}); err != nil {
log.Print(err)
return
}
}
var resultsTemplate = template.Must(template.New("results").Parse(`
<html>
<head/>
<body>
<ol>
{{range .Results}}
<li>{{.Title}} - <a href="{{.URL}}">{{.URL}}</a></li>
{{end}}
</ol>
<p>{{len .Results}} results in {{.Elapsed}}; timeout {{.Timeout}}</p>
</body>
</html>
`))
view raw server.go hosted with ❤ by GitHub
// Package userip provides functions for extracting a user IP address from a
// request and associating it with a Context.
//
// This package is an example to accompany https://blog.golang.org/context.
// It is not intended for use by others.
package userip
import (
"context"
"fmt"
"net"
"net/http"
)
// FromRequest extracts the user IP address from req, if present.
func FromRequest(req *http.Request) (net.IP, error) {
ip, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
}
userIP := net.ParseIP(ip)
if userIP == nil {
return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
}
return userIP, nil
}
// The key type is unexported to prevent collisions with context keys defined in
// other packages.
type key int
// userIPkey is the context key for the user IP address. Its value of zero is
// arbitrary. If this package defined other context keys, they would have
// different integer values.
const userIPKey key = 0
// NewContext returns a new Context carrying userIP.
func NewContext(ctx context.Context, userIP net.IP) context.Context {
return context.WithValue(ctx, userIPKey, userIP)
}
// FromContext extracts the user IP address from ctx, if present.
func FromContext(ctx context.Context) (net.IP, bool) {
// ctx.Value returns nil if ctx has no value for the key;
// the net.IP type assertion returns ok=false for nil.
userIP, ok := ctx.Value(userIPKey).(net.IP)
return userIP, ok
}
view raw userip.go hosted with ❤ by GitHub

26 tháng 1, 2019

Một số điểm cải thiện tốc độ của Go

Một số điểm cải thiện tốc độ của Go

1. Xử lý và lưu trữ dữ liệu

Tính năng đầu tiên tôi muốn nói đến đó là việc xử lý và lưu trữ các giá trị hiệu quả của Go Go

var gocon int32 = 2019

Ở trên là một ví dụ về một giá trị trong Go. Khi biên dịch, gocon tiêu thụ chính xác 4 byte của bộ nhớ.

So sánh Go với một vài ngôn ngữ khác:

Python

% python
>>> from sys import getsizeof
>>> gocon = 2019
>>> getsizeof(gocon)
24

Do cách Python biểu diễn các biến, để lưu một giá trị tương tự, Python cần tiêu thụ bộ nhớ gấp 6 lần. Phần bộ nhớ nhiều hơn này được Python sử dụng để theo doi thông tin của kiểu dữ liệu, tham chiếu,...

Hãy xem qua ví dụ sử dụng Java:

Java

int gocon = 2019;

Tương tự như Go, kiểu int trong Java tiêu thụ 4 byte của bộ nhớ để lưu giá trị này. Tuy nhiên, để sử dụng giá trị này trong một collection như List hoặc Map thì trình biên dịch cần phải chuyển nó về đối tượng Integer.

Java

// 16 bytes on 32 bit JVM
// 24 bytes on 64 bit JVM
Integer gocon = new Integer(2019);

Vì vậy một số nguyên trong Java thường được trông như trên và tiêu thụ 16 byte hoặc 24 byte của bộ nhớ.

Tại sao điều này lại quan trọng? Bộ nhớ rẻ và có sẵn, vậy tại sao nên nắm rõ về chi phí này?

Hình 1

Trên đây là đồ thị cho ta thấy tốc độ CPU xung nhịp và tốc độ memory bus.

Lưu ý là khoảng cách giữa tốc độ xung nhịp của CPU và tốc độ memory bus liên tục mở rộng. Sự khác biệt hiệu năng là CPU dùng bao nhiêu thời gian để đợi memory.

CPU cache

Từ cuối những năm 1960, những nhà thiết kế CPU đã hiểu hơn về vấn đề này. Giải pháp của họ là sử dụng cache, một vùng nhỏ hơn và nhanh hơn memory được chèn giữa CPU và main memory.

Ví dụ:

// Location is a point in a three dimensional space
type Location struct {
    // 8 bytes per float64
    // 24 bytes in total
    X, Y, Z float64
}

// Locations consumes 24 * 1000 bytes
var Locations [1000]Location

Trên đây là một kiểu dữ liệu Location lưu tọa độ của các đối tượng trong không gian 3 chiều. Nó được viết bằng ngôn ngữ Go, vì vậy mỗi Location tiêu thụ chính xác 24 byte của bộ nhớ.

Chúng ta có thể sử dụng kiểu dữ liệu này để tạo ra một mảng bao gồm 1,000 đối tượng Location, việc này sẽ tiêu tốn 24,000 byte của bộ nhớ.

Bên trong mảng này, cấu trúc của Location được lưu trữ tuần tự mà không phải là các con trỏ đến 1,000 cấu trúc Location được lưu trữ ngẫu nhiên. Điều này rất quan trọng vì bây giờ toàn bộ 1,000 cấu trúc Location được lưu tuần tự trong cache, được đóng gói cùng nhau.

Go cho phép tạo ra các cấu trúc dữ liệu nhỏ gọn, tránh những chi phí gián tiếp không cần thiết.

Cấu trúc dữ liệu nhỏ sẽ được cache tốt hơn.

Cache tốt hơn sẽ dẫn đến hiệu năng tốt hơn.

2. Inlining

Mỗi lời gọi hàm đều tiêu tốn chi phí. Có ba điều diễn ra khi một hàm được gọi:

  • Một stack frame mới được tạo ra và thông tin chi tiết của đối tượng gọi hàm này được lưu lại
  • Bất kì thanh ghi nào có thể được ghi đè trong suốt quá trình gọi hàm đều được lưu lại trong stack
  • Bộ vi xử lý tính toán địa chỉ của hàm đó và thực thi một nhánh đến địa chỉ mới

Ví dụ về inlining:

package util

// Max returns the larger of a or b.
func Max(a int, b int) int {
    if a > b {
        return a
    }
    return b
}
package main

import util

// Double returns twice the value of the larger of a or b.
func Double(a int, b int) int {
    return 2 * util.Max(a, b)
}

Ví dụ này cho ta thấy hàm Double gọi util.Max.

Để tránh chi phí của việc gọi hàm util.Max, trình biên dịch có thể chuyển nội dung xử lý của hàm util.Max (inline) vào bên trong hàm Double. Kết quả là sẽ trông giống như sau:

Sau khi thực hiện inline:

func Double(a int, b int) int {
    temp := b                  //
    if a > b {                 // Nội dung xử lý của hàm util.Max được sao chép vào trong hàm Double
        temp = a               //
    }                          //
    return 2 * temp
}

Sau khi thực hiện inline ta không còn thấy lời gọi hàm đến util.Max nữa, nhưng logic xử lý của hàm Double vẫn không thay đổi.

Inlining không phải chỉ có mỗi Go có, nhiều trình biên dịch hoặc ngôn ngữ JITed cũng thực hiện việc tối ưu này. Nhưng cách Go thực hiện như thế nào?

Go cài đặt điều này rất đơn giản, khi một package được biên dịch, nhưng function đủ nhỏ để thực hiện inlining đều được đánh dấu và sau đó thực hiện biên dịch như bình thường.

Sau đó cả mã nguồn của hàm đó và phiên bản của lần biên dịch đều được lưu lại.

util.a

% strings ~/pkg/linux_amd64/util.a
...
package util
        import runtime "runtime"
        func @"".Max (@"".a
2 int, @"".b
3 int) (? int) { if @"".a
2 > @"".b
3 { return @"".a
2 }; return @"".b

Lược bỏ code chết

func Test() bool {
    return false
}

func Expensive() {
    if Test() {
        // something expensive
    }
}

Trong ví dụ trên, mặc dù hàm Test luôn luôn trả về giá trị false, hàm Expensive không thể biết rằng không cần thực thi nó.

Khi hàm Test được inline, chúng ta sẽ thấy một vài thứ giống như sau:

func Expensive() {
    if false {
        // something expensive is now unreachable
    }
}

Trình biên dịch bây giờ biết rằng đoạn code này không thể truy cập. Điều này không chỉ tiết kiệm chi phí vì không phải thực hiện gọi hàm Test, nó còn tiết kiệm trong quá trình biên dịch hoặc thực thi các đoạn code không bao giờ truy cập đến.

3. Escape analysis

Garbage collection giúp Go trở thành một ngôn ngữ đơn giản hơn và an toàn hơn.

Sự xuất hiện của garbage collection không có nghĩa là nó sẽ khiến Go chậm đi, hoặc nó có thể quyết định tốc độ xử lý chương trình của bạn.

Điều đó có nghĩa là bộ nhớ được phân vùng trên heap sẽ phải đi kèm với chi phí. Nó giống như một khoản nợ làm tốn thời gian xử lý của CPU mỗi khi GC chạy đến khi bộ nhớ đó được thu hồi.

Phân vùng nhớ của process

Hình 2

Tuy nhiên ta cũng có một cùng nhớ khác được gọi là Stack. Không giống như ngôn ngữ C đó là các giá trị sẽ luôn nằm ở vùng nhớ Heap khi sử dụng malloc, hoặc trên Stack nếu được khai báo bên trong phạm vi của một hàm, Go thực hiện một cải tiến gọi là escape analysis.

Escape analysis:

  • Xác định khi nếu có bất kì tham chiếu nào đến một giá trị thoát ra khỏi hàm mà giá trị được khai báo.
  • Nếu không có tham chiếu nào thoát ra ngoài, giá trị đó có thể được lưu trữ ở trên Stack.
  • Các giá trị được lưu ở trên Stack không cần phải cấp phát hoặc thu hồi.

Hãy xem qua một vài ví dụ sau:

// Sum returns the sum of the numbers 1 to 100.
func Sum() int {
    numbers := make([]int, 100) // numbers never escapes Sum()
    for i := range numbers {
        numbers[i] = i + 1
    }
    var sum int
    for _, i := range numbers {
        sum += i
    }
    return sum
}

Hàm Sum cộng các số từ 1 đến 100 và trả về kết quả. Đoạn code thực hiện logic này khá dị, nhưng đây là một ví dụ giúp ta hiểu cách Escape Analysis hoạt động.

numbers slice chỉ được tham chiếu bên trong hàm Sum, nên trình biên dịch sẽ lưu trữ slice 100 số nguyên đó ở trên vùng nhớ Stack mà không phải Heap. Vì vậy, sẽ không cần GC thủ hồi numbers, nó sẽ tự động được thu hồi sau khi hàm Sum kết thúc.

Ví dụ 2:

const Width, Height = 640,480
type Cursor struct {
    X, Y int
}

func Center(c *Cursor) { // Center() dosen't retain a reference to c
    c.X += Width / 2
    c.Y += Height / 2
}

func CenterCursor() {
    c := new(Cursor)        // c created with new() but never visible
    Center(c)               // outside CenterCursor(), so will be
    fmt.Println(c.X, c.Y)   // allocated on the stack
}

Trong hàm CenterCursor chúng ta tạo một Cursor mới và lưu một con trỏ của nó ở trong biến c.

Sau đó ta truyền c qua hàm Center() để thực hiện di chuyển Cursor đến giữa màn hình.

Và cuối cùng ta in ra giá trị XY của Cursor.

Ngay cả khi c được khởi tạo bằng hàm new, nó sẽ không được lưu ở trong phân vùng Heap, vì không có tham chiếu c thoát ra khỏi hàm CenterCursor.

% go build -gcflags=-m esc.go
# command-line-arguments
./esc.go:26 can inline Center       # Center() is inlined into CenterCursor()
./esc.go:33 inlining call to Center #
./esc.go:6 Sum make([]int, 100) does not escape # make([]int, 100) 
./esc.go:26 Cemter c does not escape # func Center(c *Cursor)
./esc.go:32 CenterCursor new (Cursor) does not escape # c:= new(Cursor)
./esc.go:34 NewPoint ... argument does not escape # fmt.Println(c.X, c.Y)

Escape analysis được thực hiện trong quá trình biên dịch, cấp phát trên Stack sẽ luôn nhanh hơn Heap.

4. Goroutines

Go có goroutine, là nền tảng cho khái niệm concurrency trong ngôn ngữ Go.

Trong quá khứ, máy tính chỉ chạy một process trong một thời điểm, sau những năm 60 ý tưởng multiprocessing hay time-sharing trờ nên phổ biến.

Trong một hệ thống time-sharing, hệ điều hành phải liên tục chuyển sự "chăm sóc" của CPU giữa các process bằng cách ghi lại trạng thái của process hiện tại, sau đó khôi phục trạng thái của một process khác.

Đây được gọi là process switching.

Chi phí process switch

Chi phí khi thực hiện một process switch:

  • Đầu tiên là kernel cần lưu nội dung của tất cả các thanh ghi của CPU cho process đó, sau đó khôi phục các giá trị cho process khác.
  • Kernel cũng cần xóa các ánh xạ của CPU từ virtual memory đến physical memory vì chúng chỉ hợp lệ với process hiện tại
  • Cuối cùng là chi phí để thực hiện chuyển đổi context (context switch) của hệ điều hành, và chi phí của chức năng lập lịch để chọn process tiếp theo chiếm CPU

Vì một process switch có thể xảy ra ở bất kì thời điểm nào trong một quá trình thực thi process, hệ điều hành cần lưu nội dung của tất cả các thanh ghi vì nó không biết hiện tại thanh ghi nào đang được sử dụng.

Blocking syscalls

// package runtime
TEXT        ·Syscall(SB),NOSPLIT,$0-56
            CALL    runtime·entersyscall(SB)
            ...
            MOVQ    8(SP), AX       // syscall number
            SYSCALL
            ...
            CALL    runtime·exitsyscall(SB)
            RET

Thread

  • Nhiều thread có thể cùng chia sẻ không gian địa chỉ ô nhớ
  • Việc tạo và chuyển đổi nhanh hơn so với các process

Điều này dẫn đến sự phát triển của thread, về mặt khái niệm thì thread và process giống nhau nhưng các thread chia sẻ không gian địa chỉ ô nhớ.

Vì các thread chia sẻ không gian địa chỉ ô nhớ, nên chúng nhỏ hơn process và cũng nhanh hơn khi tạo và nhanh hơn khi chuyển đổi giữa các thread với nhau.

Goroutine

Goroutine lấy ý tưởng từ Thread. Goroutine là co-operatively scheduled, không dựa vào kernel để quản lý time-sharing.

Việc chuyển đổi giữa các gorountine chỉ xảy ra ở thời điểm xác định, khi một lời gọi rõ ràng được tạo tới Go runtime scheduler.

Trình biên dịch biết các thanh ghi nào đang sử dụng và lưu chúng tự động.

z_img_001.jpg
view raw z_img_001.jpg hosted with ❤ by GitHub
z_img_002.jpg
view raw z_img_002.jpg hosted with ❤ by GitHub